1704 lines
70 KiB
Plaintext
1704 lines
70 KiB
Plaintext
@using EntKube.Web.Data
|
|
@using EntKube.Web.Services
|
|
@inject KeycloakService KeycloakService
|
|
@inject StorageService StorageService
|
|
@inject ComponentLifecycleService LifecycleService
|
|
|
|
@if (loading)
|
|
{
|
|
<div class="text-center py-5">
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
<p class="text-muted mt-2">Scanning for Keycloak instances...</p>
|
|
</div>
|
|
}
|
|
else if (selectedRealm is not null && selectedKeycloak is not null)
|
|
{
|
|
@* ── Level 3: Realm detail ── *@
|
|
<nav class="mb-3">
|
|
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="BackToRealms">
|
|
<i class="bi bi-arrow-left me-1"></i>Back to Realms
|
|
</button>
|
|
</nav>
|
|
|
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<i class="bi bi-shield-lock fs-4 text-primary"></i>
|
|
<div>
|
|
<h5 class="mb-0">@selectedRealm.DisplayName</h5>
|
|
<small class="text-muted font-monospace">@selectedRealm.RealmName</small>
|
|
</div>
|
|
</div>
|
|
@if (selectedRealm.LinkedApp is not null)
|
|
{
|
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle small">
|
|
<i class="bi bi-link-45deg me-1"></i>@selectedRealm.LinkedApp.Customer.Name / @selectedRealm.LinkedApp.Name
|
|
</span>
|
|
}
|
|
</div>
|
|
|
|
<ul class="nav nav-tabs mb-3 flex-wrap">
|
|
@foreach (string tab in new[] { "users", "idp", "groups", "orgs", "themes", "backup", "portal" })
|
|
{
|
|
string label = tab switch
|
|
{
|
|
"users" => "Users",
|
|
"idp" => "Identity Providers",
|
|
"groups" => "Groups",
|
|
"orgs" => "Organizations",
|
|
"themes" => "Themes",
|
|
"backup" => "Backup & Restore",
|
|
"portal" => "Customer Portal",
|
|
_ => tab
|
|
};
|
|
<li class="nav-item">
|
|
<button class="nav-link @(realmTab == tab ? "active" : "")"
|
|
@onclick="() => SwitchRealmTab(tab)">@label</button>
|
|
</li>
|
|
}
|
|
</ul>
|
|
|
|
@* ── Users ── *@
|
|
@if (realmTab == "users")
|
|
{
|
|
@if (loadingUsers)
|
|
{
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<span class="text-muted small">@(users?.Count ?? 0) users</span>
|
|
<button class="btn btn-sm btn-primary" @onclick="() => showAddUser = !showAddUser">
|
|
<i class="bi bi-person-plus me-1"></i>Add User
|
|
</button>
|
|
</div>
|
|
|
|
@if (showAddUser)
|
|
{
|
|
<div class="card mb-3 border-primary-subtle">
|
|
<div class="card-body">
|
|
@if (userError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small">@userError</div>
|
|
}
|
|
<div class="row g-2">
|
|
<div class="col-md-4">
|
|
<input class="form-control form-control-sm" placeholder="Username" @bind="newUserUsername" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<input class="form-control form-control-sm" placeholder="Email" @bind="newUserEmail" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<input class="form-control form-control-sm" placeholder="First name" @bind="newUserFirstName" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<input class="form-control form-control-sm" placeholder="Last name" @bind="newUserLastName" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<input type="password" class="form-control form-control-sm" placeholder="Password (optional)" @bind="newUserPassword" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-2 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="AddUser" disabled="@savingUser">
|
|
@if (savingUser) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddUser = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (users is not null)
|
|
{
|
|
<div class="list-group">
|
|
@foreach (KeycloakUserInfo user in users)
|
|
{
|
|
<div class="list-group-item py-2">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<strong>@user.Username</strong>
|
|
<span class="text-muted small ms-2">@user.Email</span>
|
|
@if (!user.Enabled)
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
|
}
|
|
@if (!user.EmailVerified)
|
|
{
|
|
<span class="badge bg-warning-subtle text-warning ms-1 small">Unverified</span>
|
|
}
|
|
@if (user.Attributes.Count > 0)
|
|
{
|
|
<div class="mt-1 d-flex flex-wrap gap-1">
|
|
@foreach (KeyValuePair<string, List<string>> attr in user.Attributes)
|
|
{
|
|
<span class="badge bg-light text-secondary border font-monospace"
|
|
style="font-size:0.7rem">
|
|
@attr.Key: @string.Join(", ", attr.Value)
|
|
</span>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-danger border-0 ms-2"
|
|
@onclick="() => DeleteUser(user.Id)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
@* ── Identity Providers ── *@
|
|
@if (realmTab == "idp")
|
|
{
|
|
@if (loadingIdps)
|
|
{
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<span class="text-muted small">@(idps?.Count ?? 0) providers</span>
|
|
<button class="btn btn-sm btn-primary" @onclick="() => showAddIdp = !showAddIdp">
|
|
<i class="bi bi-plus-circle me-1"></i>Add Provider
|
|
</button>
|
|
</div>
|
|
|
|
@if (showAddIdp)
|
|
{
|
|
<div class="card mb-3 border-primary-subtle">
|
|
<div class="card-body">
|
|
@if (idpError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small">@idpError</div>
|
|
}
|
|
<div class="row g-2">
|
|
<div class="col-md-3">
|
|
<input class="form-control form-control-sm" placeholder="Alias (e.g. google)" @bind="newIdpAlias" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<select class="form-select form-select-sm" @bind="newIdpProviderId">
|
|
<option value="oidc">OpenID Connect</option>
|
|
<option value="saml">SAML</option>
|
|
<option value="google">Google</option>
|
|
<option value="github">GitHub</option>
|
|
<option value="microsoft">Microsoft</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<input class="form-control form-control-sm" placeholder="Client ID" @bind="newIdpClientId" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<input type="password" class="form-control form-control-sm" placeholder="Client Secret" @bind="newIdpClientSecret" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-2 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="AddIdp" disabled="@savingIdp">
|
|
@if (savingIdp) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Add
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddIdp = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (idps is not null)
|
|
{
|
|
<div class="list-group">
|
|
@foreach (KeycloakIdpInfo idp in idps)
|
|
{
|
|
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
|
|
<div>
|
|
<i class="bi bi-box-arrow-in-right me-2 text-muted"></i>
|
|
<strong>@idp.Alias</strong>
|
|
<span class="badge bg-secondary-subtle text-secondary ms-2 small">@idp.ProviderId</span>
|
|
@if (!idp.Enabled)
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
|
}
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-danger border-0"
|
|
@onclick="() => DeleteIdp(idp.Alias)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
@* ── Groups ── *@
|
|
@if (realmTab == "groups")
|
|
{
|
|
@if (loadingGroups)
|
|
{
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<span class="text-muted small">@(groups?.Count ?? 0) groups</span>
|
|
<button class="btn btn-sm btn-primary" @onclick="() => showAddGroup = !showAddGroup">
|
|
<i class="bi bi-people me-1"></i>Add Group
|
|
</button>
|
|
</div>
|
|
|
|
@if (showAddGroup)
|
|
{
|
|
<div class="d-flex gap-2 mb-3">
|
|
<input class="form-control form-control-sm" placeholder="Group name" @bind="newGroupName" />
|
|
<button class="btn btn-sm btn-primary" @onclick="AddGroup" disabled="@savingGroup">
|
|
@if (savingGroup) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddGroup = false">Cancel</button>
|
|
</div>
|
|
@if (groupError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small mb-3">@groupError</div>
|
|
}
|
|
}
|
|
|
|
@if (groups is not null)
|
|
{
|
|
<div class="list-group">
|
|
@foreach (KeycloakGroupInfo g in groups)
|
|
{
|
|
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
|
|
<span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span>
|
|
<button class="btn btn-sm btn-outline-danger border-0"
|
|
@onclick="() => DeleteGroup(g.Id)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
@* ── Organizations ── *@
|
|
@if (realmTab == "orgs")
|
|
{
|
|
@if (loadingOrgs)
|
|
{
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<span class="text-muted small">@(orgs?.Count ?? 0) organizations (Keycloak 26+)</span>
|
|
<button class="btn btn-sm btn-primary" @onclick="() => showAddOrg = !showAddOrg">
|
|
<i class="bi bi-building me-1"></i>Add Organization
|
|
</button>
|
|
</div>
|
|
|
|
@if (showAddOrg)
|
|
{
|
|
<div class="card mb-3 border-primary-subtle">
|
|
<div class="card-body">
|
|
@if (orgError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small">@orgError</div>
|
|
}
|
|
<div class="row g-2">
|
|
<div class="col-md-4">
|
|
<input class="form-control form-control-sm" placeholder="Name" @bind="newOrgName" />
|
|
</div>
|
|
<div class="col-md-8">
|
|
<input class="form-control form-control-sm" placeholder="Description (optional)" @bind="newOrgDescription" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-2 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="AddOrg" disabled="@savingOrg">
|
|
@if (savingOrg) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddOrg = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (orgs is not null)
|
|
{
|
|
<div class="list-group">
|
|
@foreach (KeycloakOrganizationInfo org in orgs)
|
|
{
|
|
<div class="list-group-item py-2">
|
|
<strong>@org.Name</strong>
|
|
@if (!string.IsNullOrWhiteSpace(org.Description))
|
|
{
|
|
<span class="text-muted small ms-2">@org.Description</span>
|
|
}
|
|
@if (!org.Enabled)
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
@* ── Themes ── *@
|
|
@if (realmTab == "themes")
|
|
{
|
|
@if (realmDetails is null)
|
|
{
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
}
|
|
else
|
|
{
|
|
@if (realmThemeError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small mb-3">@realmThemeError</div>
|
|
}
|
|
<div class="row g-3" style="max-width:480px">
|
|
<div class="col-12">
|
|
<label class="form-label small fw-semibold">Login Theme</label>
|
|
<select class="form-select form-select-sm" @bind="editLoginTheme">
|
|
<option value="">(default)</option>
|
|
@foreach (string t in realmDetails.Themes)
|
|
{
|
|
<option value="@t">@t</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label small fw-semibold">Account Console Theme</label>
|
|
<select class="form-select form-select-sm" @bind="editAccountTheme">
|
|
<option value="">(default)</option>
|
|
@foreach (string t in realmDetails.Themes)
|
|
{
|
|
<option value="@t">@t</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-12">
|
|
<button class="btn btn-sm btn-primary" @onclick="SaveThemes" disabled="@savingThemes">
|
|
@if (savingThemes) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
@* ── Backup & Restore ── *@
|
|
@if (realmTab == "backup")
|
|
{
|
|
@if (loadingBackups)
|
|
{
|
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
}
|
|
else
|
|
{
|
|
@if (backupError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small mb-3">@backupError</div>
|
|
}
|
|
|
|
<div class="card mb-4">
|
|
<div class="card-body">
|
|
<h6 class="card-title">Create Backup</h6>
|
|
<div class="row g-2 align-items-end" style="max-width:400px">
|
|
<div class="col">
|
|
<label class="form-label small">Storage Bucket</label>
|
|
<select class="form-select form-select-sm" @bind="backupStorageLinkId">
|
|
<option value="@Guid.Empty">Select bucket...</option>
|
|
@if (storageLinks is not null)
|
|
{
|
|
@foreach (StorageLink link in storageLinks)
|
|
{
|
|
<option value="@link.Id">@link.Name</option>
|
|
}
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-sm btn-primary" @onclick="CreateBackup"
|
|
disabled="@(creatingBackup || backupStorageLinkId == Guid.Empty)">
|
|
@if (creatingBackup) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Backup Now
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (backups is not null && backups.Count > 0)
|
|
{
|
|
<h6>Backups</h6>
|
|
<div class="list-group">
|
|
@foreach (KeycloakBackup b in backups)
|
|
{
|
|
<div class="list-group-item py-2">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<span class="font-monospace small">@b.RealmName</span>
|
|
<span class="text-muted small ms-2">@b.CreatedAt.ToString("yyyy-MM-dd HH:mm")</span>
|
|
@if (b.Status == KeycloakBackupStatus.Ready)
|
|
{
|
|
<span class="badge bg-success-subtle text-success ms-1 small">Ready</span>
|
|
<span class="text-muted small ms-1">@FormatSize(b.SizeBytes)</span>
|
|
}
|
|
else if (b.Status == KeycloakBackupStatus.Failed)
|
|
{
|
|
<span class="badge bg-danger-subtle text-danger ms-1 small">Failed</span>
|
|
<span class="text-muted small ms-1">@b.LastError</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Creating</span>
|
|
}
|
|
</div>
|
|
@if (b.Status == KeycloakBackupStatus.Ready)
|
|
{
|
|
<div class="d-flex gap-1">
|
|
<button class="btn btn-sm btn-outline-primary border-0"
|
|
@onclick="() => RestoreBackup(b.Id)">
|
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger border-0"
|
|
@onclick="() => DeleteBackup(b.Id)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
@* ── Customer Portal ── *@
|
|
@if (realmTab == "portal")
|
|
{
|
|
@if (portalError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small mb-3">@portalError</div>
|
|
}
|
|
@if (selectedRealm.LinkedApp is not null)
|
|
{
|
|
<div class="alert alert-success d-flex align-items-center gap-2 py-2 mb-3">
|
|
<i class="bi bi-link-45deg"></i>
|
|
<span>Linked to <strong>@selectedRealm.LinkedApp.Customer.Name / @selectedRealm.LinkedApp.Name</strong></span>
|
|
<button class="btn btn-sm btn-outline-secondary ms-auto" @onclick="UnlinkApp">Unlink</button>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted small mb-3">Link this realm to a customer app so the customer can manage users, groups, and IdPs through their portal.</p>
|
|
<div class="row g-2 align-items-end" style="max-width:400px">
|
|
<div class="col">
|
|
<label class="form-label small">App</label>
|
|
<select class="form-select form-select-sm" @bind="linkAppId">
|
|
<option value="@Guid.Empty">Select app...</option>
|
|
@if (apps is not null)
|
|
{
|
|
@foreach (Data.App app in apps)
|
|
{
|
|
<option value="@app.Id">@app.Customer.Name / @app.Name</option>
|
|
}
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-sm btn-primary" @onclick="LinkApp"
|
|
disabled="@(linkAppId == Guid.Empty)">
|
|
Link
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
else if (selectedKeycloak is not null)
|
|
{
|
|
@* ── Level 2: Realm list for a specific Keycloak ── *@
|
|
<nav class="mb-3">
|
|
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="BackToList">
|
|
<i class="bi bi-arrow-left me-1"></i>All Keycloak Instances
|
|
</button>
|
|
</nav>
|
|
|
|
<div class="d-flex align-items-center gap-2 mb-4">
|
|
<i class="bi bi-person-badge fs-4 text-primary"></i>
|
|
<div>
|
|
<h5 class="mb-0">@selectedKeycloak.Component.Name
|
|
<span class="badge bg-secondary-subtle text-secondary ms-2 small">@selectedKeycloak.Component.Cluster.Name</span>
|
|
</h5>
|
|
@if (selectedKeycloak.Config?.AdminUrl is not null)
|
|
{
|
|
<small class="text-muted font-monospace">@selectedKeycloak.Config.AdminUrl</small>
|
|
}
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-secondary ms-auto" @onclick="() => showEditConfig = !showEditConfig">
|
|
<i class="bi bi-gear me-1"></i>Settings
|
|
</button>
|
|
</div>
|
|
|
|
@if (showEditConfig && selectedKeycloak.Config is not null)
|
|
{
|
|
<div class="card mb-4 border-secondary-subtle">
|
|
<div class="card-header small fw-semibold bg-secondary-subtle">Keycloak Settings</div>
|
|
<div class="card-body">
|
|
@if (configError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small">@configError</div>
|
|
}
|
|
<div class="row g-2" style="max-width:560px">
|
|
<div class="col-md-8">
|
|
<label class="form-label small">Admin URL</label>
|
|
<input class="form-control form-control-sm" placeholder="https://auth.example.com"
|
|
@bind="editAdminUrl" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Admin Username</label>
|
|
<input class="form-control form-control-sm" @bind="editAdminUsername" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label small">New Admin Password <span class="text-muted">(leave blank to keep)</span></label>
|
|
<input type="password" class="form-control form-control-sm" @bind="editAdminPassword" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-2 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="SaveConfig" disabled="@savingConfig">
|
|
@if (savingConfig) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Save
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showEditConfig = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (!selectedKeycloak.IsConfigured)
|
|
{
|
|
@* Setup form for unconfigured keycloak *@
|
|
<div class="card border-warning-subtle">
|
|
<div class="card-header small fw-semibold bg-warning-subtle text-warning-emphasis">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>Not Configured
|
|
</div>
|
|
<div class="card-body">
|
|
@if (selectedKeycloak.Component.Status == ComponentStatus.Failed)
|
|
{
|
|
<div class="alert alert-danger py-2 small mb-3">
|
|
<i class="bi bi-x-circle me-1"></i>
|
|
The install failed — likely because the database credentials weren't available before
|
|
the pod started. Configure the database here, then retry the install from the
|
|
<strong>Cluster</strong> tab.
|
|
</div>
|
|
}
|
|
<p class="text-muted small mb-3">
|
|
Enter the admin credentials for this Keycloak instance. Optionally link a managed
|
|
CNPG database — if the instance already runs against its own Postgres, leave the
|
|
database unselected. Credentials are synced to a Kubernetes Secret in the
|
|
<code>@(selectedKeycloak.Component.Namespace ?? "keycloak")</code> namespace.
|
|
</p>
|
|
@if (setupError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small">@setupError</div>
|
|
}
|
|
<div class="row g-2" style="max-width:560px">
|
|
<div class="col-12">
|
|
<label class="form-label small fw-semibold">Database</label>
|
|
<select class="form-select form-select-sm" @bind="setupDatabaseId">
|
|
<option value="@Guid.Empty">Select CNPG database...</option>
|
|
@if (availableDatabases is not null)
|
|
{
|
|
@foreach (CnpgDatabase db in availableDatabases)
|
|
{
|
|
<option value="@db.Id">@db.CnpgCluster.Name / @db.Name (@db.Owner)</option>
|
|
}
|
|
}
|
|
</select>
|
|
@if (availableDatabases is not null && availableDatabases.Count == 0)
|
|
{
|
|
<div class="form-text text-warning">
|
|
No ready CNPG databases found on this cluster. Create one in the Databases tab first.
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="col-md-8">
|
|
<label class="form-label small fw-semibold">Admin URL</label>
|
|
<input class="form-control form-control-sm"
|
|
placeholder="https://auth.example.com"
|
|
@bind="setupAdminUrl" />
|
|
@if (!string.IsNullOrWhiteSpace(selectedKeycloak.SuggestedAdminUrl))
|
|
{
|
|
<div class="form-text">
|
|
Suggested: <a href="#" @onclick:preventDefault
|
|
@onclick="UseSuggestedUrl">
|
|
@selectedKeycloak.SuggestedAdminUrl
|
|
</a>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small fw-semibold">Admin Username</label>
|
|
<input class="form-control form-control-sm" @bind="setupUsername" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label small fw-semibold">Admin Password</label>
|
|
<input type="password" class="form-control form-control-sm" @bind="setupPassword" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<button class="btn btn-sm btn-primary" @onclick="ConfigureKeycloak"
|
|
disabled="@(configuringKeycloak || string.IsNullOrWhiteSpace(setupAdminUrl) || string.IsNullOrWhiteSpace(setupPassword))">
|
|
@if (configuringKeycloak) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
<i class="bi bi-check-circle me-1"></i>Configure
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
@* Instance-level tabs: Realms | Themes *@
|
|
<ul class="nav nav-tabs mb-4">
|
|
<li class="nav-item">
|
|
<button class="nav-link @(instanceTab == "realms" ? "active" : "")"
|
|
@onclick='() => SwitchInstanceTab("realms")'>
|
|
<i class="bi bi-shield-lock me-1"></i>Realms
|
|
</button>
|
|
</li>
|
|
<li class="nav-item">
|
|
<button class="nav-link @(instanceTab == "themes" ? "active" : "")"
|
|
@onclick='() => SwitchInstanceTab("themes")'>
|
|
<i class="bi bi-palette me-1"></i>Themes
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
@if (instanceTab == "themes")
|
|
{
|
|
@* ── Themes tab ── *@
|
|
<div class="d-flex gap-2 mb-3 flex-wrap">
|
|
@foreach (string type in new[] { "login", "account", "admin", "email" })
|
|
{
|
|
<button class="btn btn-sm @(themeType == type ? "btn-primary" : "btn-outline-secondary")"
|
|
@onclick="() => SwitchThemeType(type)">
|
|
@(char.ToUpper(type[0]) + type[1..])
|
|
</button>
|
|
}
|
|
</div>
|
|
|
|
@if (loadingThemes)
|
|
{
|
|
<div class="spinner-border spinner-border-sm text-primary me-2"></div>
|
|
<span class="text-muted small">Loading themes...</span>
|
|
}
|
|
else
|
|
{
|
|
@if (availableThemes is not null && availableThemes.TryGetValue(themeType, out List<string>? themeNames) && themeNames.Count > 0)
|
|
{
|
|
<div class="mb-3">
|
|
<div class="text-muted small mb-1 fw-semibold">Available themes</div>
|
|
<div class="d-flex flex-wrap gap-1">
|
|
@foreach (string name in themeNames)
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary border font-monospace">@name</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label small fw-semibold">Custom CSS override</label>
|
|
<div class="form-text mb-1">
|
|
Stored in EntKube. Apply to realms individually via the realm's Themes tab.
|
|
</div>
|
|
<textarea class="form-control font-monospace"
|
|
rows="16"
|
|
style="font-size:0.78rem; resize:vertical"
|
|
placeholder="/* Custom CSS for @themeType theme */"
|
|
@bind="themeCss"
|
|
@bind:event="oninput"></textarea>
|
|
</div>
|
|
<div class="d-flex gap-2 align-items-center">
|
|
<button class="btn btn-sm btn-primary" @onclick="SaveThemeCss" disabled="@savingTheme">
|
|
@if (savingTheme) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
<i class="bi bi-floppy me-1"></i>Save
|
|
</button>
|
|
@if (themeSaved)
|
|
{
|
|
<span class="text-success small"><i class="bi bi-check-circle me-1"></i>Saved</span>
|
|
}
|
|
@if (themeError is not null)
|
|
{
|
|
<span class="text-danger small">@themeError</span>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
@* Realms list *@
|
|
@if (realmSyncError is not null)
|
|
{
|
|
<div class="alert alert-warning py-2 small d-flex align-items-start gap-2 mb-3">
|
|
<i class="bi bi-exclamation-triangle-fill mt-1 flex-shrink-0"></i>
|
|
<div>
|
|
<strong>Could not reach Keycloak</strong> — showing cached realms.<br />
|
|
<span class="text-muted">@realmSyncError</span>
|
|
</div>
|
|
</div>
|
|
}
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<span class="text-muted small">@(realms?.Count ?? 0) realms</span>
|
|
<button class="btn btn-sm btn-primary" @onclick="() => showCreateRealm = !showCreateRealm">
|
|
<i class="bi bi-plus-circle me-1"></i>New Realm
|
|
</button>
|
|
</div>
|
|
|
|
@if (showCreateRealm)
|
|
{
|
|
<div class="card mb-3 border-primary-subtle">
|
|
<div class="card-body">
|
|
@if (realmError is not null)
|
|
{
|
|
<div class="alert alert-danger py-2 small">@realmError</div>
|
|
}
|
|
<div class="row g-2">
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Realm ID</label>
|
|
<input class="form-control form-control-sm" placeholder="e.g. acme-prod" @bind="newRealmName" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Display Name</label>
|
|
<input class="form-control form-control-sm" placeholder="e.g. Acme Production" @bind="newRealmDisplayName" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Login Theme <span class="text-muted">(optional)</span></label>
|
|
<input class="form-control form-control-sm" placeholder="keycloak" @bind="newRealmTheme" />
|
|
</div>
|
|
</div>
|
|
<div class="mt-2 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="CreateRealm" disabled="@creatingRealm">
|
|
@if (creatingRealm) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showCreateRealm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (realms is not null)
|
|
{
|
|
@if (realms.Count == 0)
|
|
{
|
|
<div class="text-center py-4 text-muted">
|
|
<i class="bi bi-shield-lock display-6 d-block mb-2"></i>
|
|
No realms yet. Create one above.
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="list-group">
|
|
@foreach (KeycloakRealm realm in realms)
|
|
{
|
|
<div class="list-group-item d-flex align-items-center py-2">
|
|
<div class="flex-grow-1 d-flex align-items-center gap-2 cursor-pointer"
|
|
@onclick="() => OpenRealm(realm)" style="cursor:pointer">
|
|
<i class="bi bi-shield-lock text-primary fs-5"></i>
|
|
<div>
|
|
<strong>@realm.DisplayName</strong>
|
|
<span class="text-muted small ms-2 font-monospace">@realm.RealmName</span>
|
|
@if (!realm.Enabled)
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
|
}
|
|
@if (realm.LinkedApp is not null)
|
|
{
|
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle ms-1 small">
|
|
<i class="bi bi-link-45deg me-1"></i>@realm.LinkedApp.Customer.Name / @realm.LinkedApp.Name
|
|
</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-1">
|
|
<button class="btn btn-sm btn-outline-primary border-0"
|
|
@onclick="() => OpenRealm(realm)">
|
|
<i class="bi bi-gear"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger border-0"
|
|
@onclick="() => DeleteRealm(realm.Id)"
|
|
disabled="@(deletingRealmId == realm.Id)">
|
|
@if (deletingRealmId == realm.Id)
|
|
{
|
|
<span class="spinner-border spinner-border-sm"></span>
|
|
}
|
|
else
|
|
{
|
|
<i class="bi bi-trash"></i>
|
|
}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
} @* end realms tab else *@
|
|
}
|
|
}
|
|
else
|
|
{
|
|
@* ── Level 1: Keycloak instance list ── *@
|
|
@if (detectedKeycloaks is null || detectedKeycloaks.Count == 0)
|
|
{
|
|
<div class="text-center py-5 text-muted">
|
|
<i class="bi bi-person-badge display-4 d-block mb-3"></i>
|
|
<p class="mb-1">No Keycloak instances found on this tenant's clusters.</p>
|
|
<small>Install the Keycloak component from the Components tab on one of your clusters.</small>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted small mb-3">
|
|
Detected @detectedKeycloaks.Count Keycloak @(detectedKeycloaks.Count == 1 ? "instance" : "instances").
|
|
Select one to manage realms, users, identity providers, and backups.
|
|
</p>
|
|
|
|
<div class="row g-3">
|
|
@foreach (DetectedKeycloak kc in detectedKeycloaks)
|
|
{
|
|
<div class="col-md-6 col-lg-4">
|
|
<div class="card h-100 @(kc.IsConfigured ? "" : "border-warning-subtle")">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-start justify-content-between mb-2">
|
|
<div>
|
|
<h6 class="mb-0">@kc.Component.Name</h6>
|
|
<small class="text-muted">@kc.Component.Cluster.Name</small>
|
|
</div>
|
|
<div class="d-flex flex-column align-items-end gap-1">
|
|
@if (kc.Component.Status == ComponentStatus.Failed)
|
|
{
|
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle small">
|
|
<i class="bi bi-x-circle me-1"></i>Install failed
|
|
</span>
|
|
}
|
|
@if (kc.IsConfigured)
|
|
{
|
|
<span class="badge bg-success-subtle text-success border border-success-subtle small">
|
|
<i class="bi bi-check-circle me-1"></i>Configured
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-warning-subtle text-warning border border-warning-subtle small">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>Setup needed
|
|
</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
@if (kc.Config?.AdminUrl is not null)
|
|
{
|
|
<div class="text-muted small font-monospace text-truncate mb-1">@kc.Config.AdminUrl</div>
|
|
}
|
|
@if (kc.Config?.CnpgDatabase is not null)
|
|
{
|
|
<div class="small mb-2">
|
|
<i class="bi bi-database me-1 text-muted"></i>
|
|
@kc.Config.CnpgDatabase.CnpgCluster.Name / @kc.Config.CnpgDatabase.Name
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="card-footer bg-transparent border-0 pt-0">
|
|
<button class="btn btn-sm btn-primary w-100" @onclick="() => SelectKeycloak(kc)">
|
|
@if (kc.IsConfigured)
|
|
{
|
|
<i class="bi bi-shield-lock me-1"></i>
|
|
<span>Manage Realms</span>
|
|
}
|
|
else
|
|
{
|
|
<i class="bi bi-gear me-1"></i>
|
|
<span>Configure</span>
|
|
}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public Guid TenantId { get; set; }
|
|
|
|
// ── Load state ────────────────────────────────────────────────────────────
|
|
private bool loading = true;
|
|
private List<DetectedKeycloak>? detectedKeycloaks;
|
|
private List<KeycloakRealm>? realms;
|
|
private List<CnpgDatabase>? availableDatabases;
|
|
private List<StorageLink>? storageLinks;
|
|
private List<Data.App>? apps;
|
|
|
|
// ── Navigation ────────────────────────────────────────────────────────────
|
|
private DetectedKeycloak? selectedKeycloak;
|
|
private KeycloakRealm? selectedRealm;
|
|
private string realmTab = "users";
|
|
private string instanceTab = "realms";
|
|
|
|
// ── Themes tab ───────────────────────────────────────────────────────────
|
|
private string themeType = "login";
|
|
private Dictionary<string, List<string>>? availableThemes;
|
|
private bool loadingThemes;
|
|
private string? themeCss;
|
|
private bool savingTheme;
|
|
private bool themeSaved;
|
|
private string? themeError;
|
|
|
|
// ── Per-tab lazy data ─────────────────────────────────────────────────────
|
|
private List<KeycloakUserInfo>? users;
|
|
private List<KeycloakIdpInfo>? idps;
|
|
private List<KeycloakGroupInfo>? groups;
|
|
private List<KeycloakOrganizationInfo>? orgs;
|
|
private List<KeycloakBackup>? backups;
|
|
private KeycloakRealmDetails? realmDetails;
|
|
|
|
private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs, loadingBackups;
|
|
|
|
// ── Setup form ────────────────────────────────────────────────────────────
|
|
private bool configuringKeycloak;
|
|
private string? setupError;
|
|
private Guid setupDatabaseId;
|
|
private string setupAdminUrl = "";
|
|
private string setupUsername = "admin";
|
|
private string setupPassword = "";
|
|
|
|
// ── Edit config form ──────────────────────────────────────────────────────
|
|
private bool showEditConfig;
|
|
private bool savingConfig;
|
|
private string? configError;
|
|
private string editAdminUrl = "";
|
|
private string editAdminUsername = "admin";
|
|
private string editAdminPassword = "";
|
|
|
|
// ── Realm form ────────────────────────────────────────────────────────────
|
|
private bool showCreateRealm;
|
|
private bool creatingRealm;
|
|
private string? realmError;
|
|
private string? realmSyncError;
|
|
private string newRealmName = "";
|
|
private string newRealmDisplayName = "";
|
|
private string newRealmTheme = "";
|
|
private Guid? deletingRealmId;
|
|
|
|
// ── User form ─────────────────────────────────────────────────────────────
|
|
private bool showAddUser;
|
|
private bool savingUser;
|
|
private string? userError;
|
|
private string newUserUsername = "";
|
|
private string newUserEmail = "";
|
|
private string newUserFirstName = "";
|
|
private string newUserLastName = "";
|
|
private string newUserPassword = "";
|
|
|
|
// ── IdP form ──────────────────────────────────────────────────────────────
|
|
private bool showAddIdp;
|
|
private bool savingIdp;
|
|
private string? idpError;
|
|
private string newIdpAlias = "";
|
|
private string newIdpProviderId = "oidc";
|
|
private string newIdpClientId = "";
|
|
private string newIdpClientSecret = "";
|
|
|
|
// ── Group form ────────────────────────────────────────────────────────────
|
|
private bool showAddGroup;
|
|
private bool savingGroup;
|
|
private string? groupError;
|
|
private string newGroupName = "";
|
|
|
|
// ── Org form ─────────────────────────────────────────────────────────────
|
|
private bool showAddOrg;
|
|
private bool savingOrg;
|
|
private string? orgError;
|
|
private string newOrgName = "";
|
|
private string newOrgDescription = "";
|
|
|
|
// ── Realm theme form ──────────────────────────────────────────────────────
|
|
private bool savingThemes;
|
|
private string? realmThemeError;
|
|
private string editLoginTheme = "";
|
|
private string editAccountTheme = "";
|
|
|
|
// ── Backup form ───────────────────────────────────────────────────────────
|
|
private bool creatingBackup;
|
|
private string? backupError;
|
|
private Guid backupStorageLinkId;
|
|
|
|
// ── Portal form ───────────────────────────────────────────────────────────
|
|
private string? portalError;
|
|
private Guid linkAppId;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId);
|
|
loading = false;
|
|
}
|
|
|
|
private async Task SelectKeycloak(DetectedKeycloak kc)
|
|
{
|
|
selectedKeycloak = kc;
|
|
realms = null;
|
|
availableDatabases = null;
|
|
setupError = null;
|
|
setupDatabaseId = Guid.Empty;
|
|
setupAdminUrl = kc.SuggestedAdminUrl ?? "";
|
|
setupPassword = "";
|
|
instanceTab = "realms";
|
|
availableThemes = null;
|
|
themeCss = null;
|
|
themeSaved = false;
|
|
themeError = null;
|
|
realmSyncError = null;
|
|
|
|
if (kc.IsConfigured)
|
|
{
|
|
try
|
|
{
|
|
realms = await KeycloakService.SyncRealmsFromKeycloakAsync(TenantId, kc.Config!.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
realmSyncError = ex.Message;
|
|
realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, kc.Config!.Id);
|
|
}
|
|
storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId);
|
|
apps ??= await KeycloakService.GetAppsAsync(TenantId);
|
|
}
|
|
else
|
|
{
|
|
availableDatabases = await KeycloakService.GetDatabasesForComponentAsync(
|
|
TenantId, kc.Component.Id);
|
|
}
|
|
|
|
if (kc.Config is not null)
|
|
{
|
|
editAdminUrl = kc.Config.AdminUrl ?? "";
|
|
editAdminUsername = kc.Config.AdminUsername;
|
|
}
|
|
}
|
|
|
|
private async Task SwitchInstanceTab(string tab)
|
|
{
|
|
instanceTab = tab;
|
|
themeSaved = false;
|
|
themeError = null;
|
|
|
|
if (tab == "themes" && selectedKeycloak?.Config is not null)
|
|
{
|
|
await LoadThemesTab();
|
|
}
|
|
}
|
|
|
|
private async Task SwitchThemeType(string type)
|
|
{
|
|
themeType = type;
|
|
themeSaved = false;
|
|
themeError = null;
|
|
await LoadThemeCss();
|
|
}
|
|
|
|
private async Task LoadThemesTab()
|
|
{
|
|
if (selectedKeycloak?.Config is null) return;
|
|
|
|
loadingThemes = true;
|
|
StateHasChanged();
|
|
|
|
try
|
|
{
|
|
availableThemes = await KeycloakService.GetAvailableThemesAsync(TenantId, selectedKeycloak.Config.Id);
|
|
await LoadThemeCss();
|
|
}
|
|
catch
|
|
{
|
|
availableThemes = [];
|
|
}
|
|
finally
|
|
{
|
|
loadingThemes = false;
|
|
}
|
|
}
|
|
|
|
private async Task LoadThemeCss()
|
|
{
|
|
if (selectedKeycloak is null) return;
|
|
themeCss = await KeycloakService.GetThemeCssAsync(TenantId, selectedKeycloak.Component.Id, themeType);
|
|
}
|
|
|
|
private async Task SaveThemeCss()
|
|
{
|
|
if (selectedKeycloak is null) return;
|
|
|
|
savingTheme = true;
|
|
themeSaved = false;
|
|
themeError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.SaveThemeCssAsync(
|
|
TenantId, selectedKeycloak.Component.Id, themeType, themeCss ?? "");
|
|
themeSaved = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
themeError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
savingTheme = false;
|
|
}
|
|
}
|
|
|
|
private void BackToList()
|
|
{
|
|
selectedKeycloak = null;
|
|
selectedRealm = null;
|
|
realms = null;
|
|
}
|
|
|
|
private async Task OpenRealm(KeycloakRealm realm)
|
|
{
|
|
selectedRealm = realm;
|
|
realmTab = "users";
|
|
users = null;
|
|
idps = null;
|
|
groups = null;
|
|
orgs = null;
|
|
backups = null;
|
|
realmDetails = null;
|
|
await SwitchRealmTab("users");
|
|
}
|
|
|
|
private void BackToRealms()
|
|
{
|
|
selectedRealm = null;
|
|
users = null;
|
|
idps = null;
|
|
groups = null;
|
|
orgs = null;
|
|
backups = null;
|
|
realmDetails = null;
|
|
}
|
|
|
|
private async Task SwitchRealmTab(string tab)
|
|
{
|
|
realmTab = tab;
|
|
|
|
if (selectedRealm is null) return;
|
|
|
|
switch (tab)
|
|
{
|
|
case "users" when users is null:
|
|
loadingUsers = true;
|
|
StateHasChanged();
|
|
try { users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id); }
|
|
catch (Exception ex) { userError = ex.Message; }
|
|
finally { loadingUsers = false; }
|
|
break;
|
|
|
|
case "idp" when idps is null:
|
|
loadingIdps = true;
|
|
StateHasChanged();
|
|
try { idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id); }
|
|
catch (Exception ex) { idpError = ex.Message; }
|
|
finally { loadingIdps = false; }
|
|
break;
|
|
|
|
case "groups" when groups is null:
|
|
loadingGroups = true;
|
|
StateHasChanged();
|
|
try { groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id); }
|
|
catch (Exception ex) { groupError = ex.Message; }
|
|
finally { loadingGroups = false; }
|
|
break;
|
|
|
|
case "orgs" when orgs is null:
|
|
loadingOrgs = true;
|
|
StateHasChanged();
|
|
try { orgs = await KeycloakService.GetOrganizationsAsync(TenantId, selectedRealm.Id); }
|
|
catch (Exception ex) { orgError = ex.Message; }
|
|
finally { loadingOrgs = false; }
|
|
break;
|
|
|
|
case "themes" when realmDetails is null:
|
|
StateHasChanged();
|
|
try
|
|
{
|
|
realmDetails = await KeycloakService.GetRealmDetailsAsync(TenantId, selectedRealm.Id);
|
|
editLoginTheme = realmDetails.LoginTheme ?? "";
|
|
editAccountTheme = realmDetails.AccountTheme ?? "";
|
|
}
|
|
catch (Exception ex) { realmThemeError = ex.Message; }
|
|
break;
|
|
|
|
case "backup" when backups is null:
|
|
loadingBackups = true;
|
|
storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId);
|
|
StateHasChanged();
|
|
try { backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id); }
|
|
catch (Exception ex) { backupError = ex.Message; }
|
|
finally { loadingBackups = false; }
|
|
break;
|
|
|
|
case "portal":
|
|
apps ??= await KeycloakService.GetAppsAsync(TenantId);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ── Setup ─────────────────────────────────────────────────────────────────
|
|
|
|
private void UseSuggestedUrl()
|
|
{
|
|
setupAdminUrl = selectedKeycloak?.SuggestedAdminUrl ?? "";
|
|
}
|
|
|
|
private async Task ConfigureKeycloak()
|
|
{
|
|
if (selectedKeycloak is null) return;
|
|
|
|
configuringKeycloak = true;
|
|
setupError = null;
|
|
|
|
try
|
|
{
|
|
KeycloakComponentConfig config = await KeycloakService.ConfigureAsync(
|
|
TenantId,
|
|
selectedKeycloak.Component.Id,
|
|
setupDatabaseId == Guid.Empty ? null : setupDatabaseId,
|
|
setupUsername,
|
|
setupPassword,
|
|
setupAdminUrl);
|
|
|
|
// Sync the credentials to the K8s secret immediately so the pod can start.
|
|
await LifecycleService.SyncComponentSecretsAsync(selectedKeycloak.Component.Id);
|
|
|
|
// Import any realms that already exist in this Keycloak instance.
|
|
await KeycloakService.SyncRealmsFromKeycloakAsync(TenantId, config.Id);
|
|
|
|
// Refresh detection list and re-select the now-configured keycloak.
|
|
detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId);
|
|
DetectedKeycloak? updated = detectedKeycloaks.FirstOrDefault(
|
|
k => k.Component.Id == selectedKeycloak.Component.Id);
|
|
|
|
if (updated is not null)
|
|
{
|
|
await SelectKeycloak(updated);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
setupError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
configuringKeycloak = false;
|
|
}
|
|
}
|
|
|
|
private async Task SaveConfig()
|
|
{
|
|
if (selectedKeycloak?.Config is null) return;
|
|
|
|
savingConfig = true;
|
|
configError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.UpdateConfigAsync(
|
|
TenantId, selectedKeycloak.Config.Id,
|
|
editAdminUrl, editAdminUsername,
|
|
string.IsNullOrWhiteSpace(editAdminPassword) ? null : editAdminPassword);
|
|
|
|
showEditConfig = false;
|
|
detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId);
|
|
DetectedKeycloak? updated = detectedKeycloaks.FirstOrDefault(
|
|
k => k.Component.Id == selectedKeycloak.Component.Id);
|
|
if (updated is not null) selectedKeycloak = updated;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
configError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
savingConfig = false;
|
|
}
|
|
}
|
|
|
|
// ── Realm CRUD ────────────────────────────────────────────────────────────
|
|
|
|
private async Task CreateRealm()
|
|
{
|
|
if (selectedKeycloak?.Config is null) return;
|
|
|
|
creatingRealm = true;
|
|
realmError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.CreateRealmAsync(
|
|
TenantId, selectedKeycloak.Config.Id,
|
|
newRealmName, newRealmDisplayName,
|
|
string.IsNullOrWhiteSpace(newRealmTheme) ? null : newRealmTheme);
|
|
|
|
realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id);
|
|
showCreateRealm = false;
|
|
newRealmName = "";
|
|
newRealmDisplayName = "";
|
|
newRealmTheme = "";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
realmError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
creatingRealm = false;
|
|
}
|
|
}
|
|
|
|
private async Task DeleteRealm(Guid realmId)
|
|
{
|
|
if (selectedKeycloak?.Config is null) return;
|
|
|
|
deletingRealmId = realmId;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.DeleteRealmAsync(TenantId, realmId);
|
|
realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
realmError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
deletingRealmId = null;
|
|
}
|
|
}
|
|
|
|
// ── User CRUD ─────────────────────────────────────────────────────────────
|
|
|
|
private async Task AddUser()
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
savingUser = true;
|
|
userError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.CreateUserAsync(
|
|
TenantId, selectedRealm.Id,
|
|
newUserUsername, newUserEmail, newUserFirstName, newUserLastName,
|
|
string.IsNullOrWhiteSpace(newUserPassword) ? null : newUserPassword);
|
|
|
|
users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id);
|
|
showAddUser = false;
|
|
newUserUsername = "";
|
|
newUserEmail = "";
|
|
newUserFirstName = "";
|
|
newUserLastName = "";
|
|
newUserPassword = "";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
userError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
savingUser = false;
|
|
}
|
|
}
|
|
|
|
private async Task DeleteUser(string userId)
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.DeleteUserAsync(TenantId, selectedRealm.Id, userId);
|
|
users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
userError = ex.Message;
|
|
}
|
|
}
|
|
|
|
// ── IdP CRUD ──────────────────────────────────────────────────────────────
|
|
|
|
private async Task AddIdp()
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
savingIdp = true;
|
|
idpError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.CreateIdpAsync(
|
|
TenantId, selectedRealm.Id,
|
|
newIdpAlias, newIdpProviderId, newIdpClientId, newIdpClientSecret);
|
|
|
|
idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id);
|
|
showAddIdp = false;
|
|
newIdpAlias = "";
|
|
newIdpClientId = "";
|
|
newIdpClientSecret = "";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
idpError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
savingIdp = false;
|
|
}
|
|
}
|
|
|
|
private async Task DeleteIdp(string alias)
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.DeleteIdpAsync(TenantId, selectedRealm.Id, alias);
|
|
idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
idpError = ex.Message;
|
|
}
|
|
}
|
|
|
|
// ── Group CRUD ────────────────────────────────────────────────────────────
|
|
|
|
private async Task AddGroup()
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
savingGroup = true;
|
|
groupError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.CreateGroupAsync(TenantId, selectedRealm.Id, newGroupName);
|
|
groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id);
|
|
showAddGroup = false;
|
|
newGroupName = "";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
groupError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
savingGroup = false;
|
|
}
|
|
}
|
|
|
|
private async Task DeleteGroup(string groupId)
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.DeleteGroupAsync(TenantId, selectedRealm.Id, groupId);
|
|
groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
groupError = ex.Message;
|
|
}
|
|
}
|
|
|
|
// ── Org CRUD ──────────────────────────────────────────────────────────────
|
|
|
|
private async Task AddOrg()
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
savingOrg = true;
|
|
orgError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.CreateOrganizationAsync(
|
|
TenantId, selectedRealm.Id, newOrgName,
|
|
string.IsNullOrWhiteSpace(newOrgDescription) ? null : newOrgDescription);
|
|
|
|
orgs = await KeycloakService.GetOrganizationsAsync(TenantId, selectedRealm.Id);
|
|
showAddOrg = false;
|
|
newOrgName = "";
|
|
newOrgDescription = "";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
orgError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
savingOrg = false;
|
|
}
|
|
}
|
|
|
|
// ── Themes ────────────────────────────────────────────────────────────────
|
|
|
|
private async Task SaveThemes()
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
savingThemes = true;
|
|
realmThemeError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.UpdateRealmThemesAsync(
|
|
TenantId, selectedRealm.Id,
|
|
string.IsNullOrWhiteSpace(editLoginTheme) ? null : editLoginTheme,
|
|
string.IsNullOrWhiteSpace(editAccountTheme) ? null : editAccountTheme);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
realmThemeError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
savingThemes = false;
|
|
}
|
|
}
|
|
|
|
// ── Backup ────────────────────────────────────────────────────────────────
|
|
|
|
private async Task CreateBackup()
|
|
{
|
|
if (selectedRealm is null || backupStorageLinkId == Guid.Empty) return;
|
|
|
|
creatingBackup = true;
|
|
backupError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.CreateBackupAsync(TenantId, selectedRealm.Id, backupStorageLinkId);
|
|
backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
backupError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
creatingBackup = false;
|
|
}
|
|
}
|
|
|
|
private async Task RestoreBackup(Guid backupId)
|
|
{
|
|
backupError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.RestoreBackupAsync(TenantId, backupId, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
backupError = ex.Message;
|
|
}
|
|
}
|
|
|
|
private async Task DeleteBackup(Guid backupId)
|
|
{
|
|
backupError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.DeleteBackupAsync(TenantId, backupId);
|
|
if (selectedRealm is not null)
|
|
{
|
|
backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
backupError = ex.Message;
|
|
}
|
|
}
|
|
|
|
// ── Portal ────────────────────────────────────────────────────────────────
|
|
|
|
private async Task LinkApp()
|
|
{
|
|
if (selectedRealm is null || linkAppId == Guid.Empty) return;
|
|
|
|
portalError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.LinkRealmToAppAsync(TenantId, selectedRealm.Id, linkAppId);
|
|
|
|
if (selectedKeycloak?.Config is not null)
|
|
{
|
|
realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id);
|
|
selectedRealm = realms.FirstOrDefault(r => r.Id == selectedRealm.Id);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
portalError = ex.Message;
|
|
}
|
|
}
|
|
|
|
private async Task UnlinkApp()
|
|
{
|
|
if (selectedRealm is null) return;
|
|
|
|
portalError = null;
|
|
|
|
try
|
|
{
|
|
await KeycloakService.UnlinkRealmFromAppAsync(TenantId, selectedRealm.Id);
|
|
|
|
if (selectedKeycloak?.Config is not null)
|
|
{
|
|
realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id);
|
|
selectedRealm = realms.FirstOrDefault(r => r.Id == selectedRealm.Id);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
portalError = ex.Message;
|
|
}
|
|
}
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────
|
|
|
|
private static string FormatSize(long bytes)
|
|
{
|
|
if (bytes < 1024) return $"{bytes} B";
|
|
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
|
|
return $"{bytes / (1024.0 * 1024):F1} MB";
|
|
}
|
|
}
|