4a592f9795
Closes the end-user half of the directory and adds nested LDAP groups.
The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:
- GET /api/discovery/me resolved groups from req.user.groups, which does not
exist (req.user carries memberOf), so it returned only isPublic resources
for every human caller -- "My Services" was blank for everyone. The same
read made isDirectoryAdmin() false for real admins.
- The portal's "Discover More Services" called the admin-gated endpoint and
swallowed the 403, so it never rendered for non-admins at all.
- Services reported no address, because /me had reimplemented getMyAccess
without its parent-walking resolution.
Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").
Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.
member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.
Full suite green in both resolution modes: 215 passed, 2 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1113 lines
46 KiB
Plaintext
1113 lines
46 KiB
Plaintext
<%- include('top') %>
|
|
|
|
<div class="container mt-4">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card shadow">
|
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
<div>
|
|
<i class="fa-solid fa-server"></i> Directory Management
|
|
</div>
|
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
|
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
|
|
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
|
<option value="name">Name (A-Z)</option>
|
|
<option value="kind">Kind</option>
|
|
<option value="env">Environment</option>
|
|
</select>
|
|
<div class="input-group input-group-sm shadow-sm" style="width: 230px;">
|
|
<span class="input-group-text" title="What can this user reach?"><i class="fa-solid fa-user-shield"></i></span>
|
|
<input type="text" id="user-access-uid" class="form-control" placeholder="uid…" list="access-uid-list"
|
|
onkeydown="if(event.key==='Enter'){openUserAccessModal();}">
|
|
<datalist id="access-uid-list"></datalist>
|
|
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
|
|
</div>
|
|
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
|
<i class="fas fa-plus"></i> Add Resource
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-header actionMessage" style="display:none"></div>
|
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
|
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
|
|
<a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="card-body table table-striped mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th class="ps-3">Resource</th>
|
|
<th>IP / Address</th>
|
|
<th>Access</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="resources-list" jq-repeat="resources">
|
|
<tr id="resource-row-{{id}}">
|
|
<td class="ps-3">
|
|
{{{indentHtml}}}
|
|
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
|
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
|
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
|
<span class="badge bg-light text-dark border">{{hostName}}</span>
|
|
<br>
|
|
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
|
|
<strong>{{name}}</strong>
|
|
</a>
|
|
<small class="text-muted">{{slug}}</small>
|
|
</td>
|
|
<td>
|
|
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
|
|
{{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
|
|
</td>
|
|
<td>{{{accessHtml}}}</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-primary" onclick="openEditModal('{{id}}')" title="Edit">
|
|
<i class="fa-solid fa-pen"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-success" onclick="openAddModal('{{id}}', '{{kind}}')" title="Add Child Resource">
|
|
<i class="fa-solid fa-plus"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteResource('{{id}}')">
|
|
<i class="fa-solid fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
|
|
|
|
// --- Resource modal tab content, built once. Populated via .val() in
|
|
// openAddModal/openEditModal AFTER app.modal.open() has (re)built the
|
|
// DOM from these strings -- order matters, unlike the old always-in-DOM
|
|
// static modal. ---
|
|
|
|
var generalTabHtml = `
|
|
<div class="actionMessage mb-3" style="display:none"></div>
|
|
<input type="hidden" id="res-id">
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-6">
|
|
<label class="form-label">Name</label>
|
|
<input type="text" id="res-name" class="form-control shadow-sm">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">Slug</label>
|
|
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-6">
|
|
<label class="form-label">Kind</label>
|
|
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
|
|
<option value="site">Site</option>
|
|
<option value="host">Host</option>
|
|
<option value="service">Service (App)</option>
|
|
<option value="oauth">OAuth Integration</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">Sub Type</label>
|
|
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-6">
|
|
<label class="form-label">IP Address</label>
|
|
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">Host / URI Address</label>
|
|
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-6">
|
|
<div class="form-check form-switch mt-2">
|
|
<input class="form-check-input" type="checkbox" id="res-is-production">
|
|
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Description</label>
|
|
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
|
|
</div>
|
|
|
|
<hr>
|
|
<h6 class="text-muted">Catalog appearance</h6>
|
|
<p class="text-muted small">How this resource is presented on the user-facing catalog at <code>/</code>.</p>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-4">
|
|
<label class="form-label">Icon</label>
|
|
<div class="input-group shadow-sm">
|
|
<span class="input-group-text"><i id="res-icon-preview" class="fa-solid fa-cube"></i></span>
|
|
<input type="text" id="res-icon" class="form-control font-monospace" placeholder="fa-solid fa-film">
|
|
</div>
|
|
<small class="field-help text-muted d-block">
|
|
A Font Awesome class. Blank falls back to a per-kind default.
|
|
</small>
|
|
</div>
|
|
<div class="col-8">
|
|
<label class="form-label">Tagline</label>
|
|
<input type="text" id="res-tagline" class="form-control shadow-sm" maxlength="120"
|
|
placeholder="One line shown on the catalog card">
|
|
<small class="field-help text-muted d-block">
|
|
Card-length blurb. Blank falls back to the Description above.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
var detailsTabHtml = `
|
|
<div class="row mb-3" id="site-details-container" style="display: none;">
|
|
<div class="col-12">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" id="res-is-current-site">
|
|
<label class="form-check-label" for="res-is-current-site">
|
|
Mark as Current Site
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3" id="host-parent-container" style="display: none;">
|
|
<div class="col-12">
|
|
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
|
|
<select id="res-host-id" class="form-select shadow-sm border-primary">
|
|
<option value="">-- Select Parent --</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3" id="host-details-container" style="display: none;">
|
|
<div class="col-4">
|
|
<label class="form-label">VMID</label>
|
|
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
|
|
</div>
|
|
<div class="col-4">
|
|
<label class="form-label">MAC Address</label>
|
|
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
|
|
</div>
|
|
<div class="col-4">
|
|
<label class="form-label">OS / Kernel</label>
|
|
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3" id="service-ports-container" style="display: none;">
|
|
<div class="col-6">
|
|
<label class="form-label">Internal Port</label>
|
|
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">External Port</label>
|
|
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
|
|
<small class="text-muted">Same as Internal if empty</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3" id="service-details-container" style="display: none;">
|
|
<div class="col-4">
|
|
<label class="form-label">Git Repo</label>
|
|
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
|
|
</div>
|
|
<div class="col-4">
|
|
<label class="form-label">Install Path</label>
|
|
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
|
|
</div>
|
|
<div class="col-4">
|
|
<label class="form-label">Systemd Service</label>
|
|
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
|
|
</div>
|
|
</div>
|
|
|
|
<div id="oauth-details-container" style="display: none;">
|
|
<hr>
|
|
<h5>OAuth Configuration</h5>
|
|
<div class="mb-3">
|
|
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
|
|
<small class="field-help text-muted d-block">
|
|
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
|
</small>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
|
|
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
|
|
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
|
|
</div>
|
|
<div class="row mb-3">
|
|
<div class="col-6">
|
|
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
|
|
</div>
|
|
</div>
|
|
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
|
|
<button class="btn btn-outline-warning" onclick="rotateSecret()">
|
|
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
|
|
</button>
|
|
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-6" id="external-container" style="display: none;">
|
|
<div class="form-check form-switch mt-2">
|
|
<input class="form-check-input" type="checkbox" id="res-is-external">
|
|
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
|
|
</div>
|
|
</div>
|
|
<div class="col-6" id="public-container" style="display: none;">
|
|
<div class="form-check form-switch mt-2">
|
|
<input class="form-check-input" type="checkbox" id="res-is-public">
|
|
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
var groupsTabHtml = `
|
|
<div class="mb-3" id="groups-tab-container">
|
|
<div class="actionMessage" style="display:none"></div>
|
|
<ul class="list-group mb-2 shadow-sm" id="groups-list"></ul>
|
|
<div class="input-group shadow-sm mt-2">
|
|
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
|
|
<datalist id="ldap-groups-datalist"></datalist>
|
|
<select class="form-select" id="new-group-level" style="max-width: 140px;">
|
|
<option value="member">Member</option>
|
|
<option value="owner">Owner</option>
|
|
</select>
|
|
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
var childrenTabHtml = `
|
|
<div class="mb-3">
|
|
<ul class="list-group mb-2 shadow-sm" id="children-list"></ul>
|
|
<button class="btn btn-success btn-sm" onclick="openAddModal($('#res-id').val(), $('#res-kind').val())">
|
|
<i class="fa-solid fa-plus"></i> Add Child Resource
|
|
</button>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<h6 class="text-muted">Other Relationships (advanced)</h6>
|
|
<div class="mb-3">
|
|
<ul class="list-group mb-2 shadow-sm" id="edges-list"></ul>
|
|
<div class="input-group shadow-sm mt-2">
|
|
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
|
|
<option value="parent">Has child</option>
|
|
<option value="child">Is child of</option>
|
|
</select>
|
|
<select class="form-select" id="new-edge-target">
|
|
<option value="">-- Select Resource --</option>
|
|
</select>
|
|
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
|
|
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
|
|
// URL-tracked) modal DOM. Callers then populate fields via .val() and hide
|
|
// the Groups/Children tabs in add-mode (no resource id to scope them to).
|
|
function openResourceModal(title, id) {
|
|
app.modal.open({
|
|
title: title,
|
|
size: 'lg',
|
|
tabs: [
|
|
{id: 'general', label: 'General', bodyHtml: generalTabHtml, active: true},
|
|
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
|
|
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
|
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
|
],
|
|
footer: {
|
|
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
|
|
buttonsHtml: app.modal.footerButtons({onSave: 'saveResource()', saveLabel: 'Save Resource'}),
|
|
},
|
|
url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
|
|
});
|
|
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
|
|
}
|
|
|
|
function refreshChildrenUI(resourceId) {
|
|
const kids = allEdges.filter(e => e.parentId === resourceId)
|
|
.map(e => resourcesById[e.childId]).filter(Boolean);
|
|
const $list = $('#children-list').empty();
|
|
if (!kids.length) {
|
|
$list.append('<li class="list-group-item text-muted">No child resources.</li>');
|
|
return;
|
|
}
|
|
for (const kid of kids) {
|
|
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
|
$('<a href="#" class="text-reset text-decoration-none"></a>')
|
|
.text(kid.name + ' (' + kid.slug + ')')
|
|
.on('click', function(e){ e.preventDefault(); openEditModal(kid.id); })
|
|
.appendTo($li);
|
|
$('<span class="badge bg-secondary"></span>').text(kid.kind).appendTo($li);
|
|
$list.append($li);
|
|
}
|
|
}
|
|
|
|
var resourcesById = {};
|
|
var allGroups = [];
|
|
var allEdges = [];
|
|
var rawResources = [];
|
|
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
|
|
var accessSummary = {};
|
|
|
|
$(document).ready(async function() {
|
|
await loadResources();
|
|
const slug = app.modal.deepLinkSlug('/directory');
|
|
if (slug) {
|
|
const match = Object.values(resourcesById).find(r => r.slug === slug);
|
|
if (match) openEditModal(match.id);
|
|
}
|
|
});
|
|
|
|
async function loadResources() {
|
|
try {
|
|
const [resResources, resGroups, resEdges, resAccess] = await Promise.all([
|
|
app.api.get('directory-admin/resources'),
|
|
app.api.get('directory-admin/groups'),
|
|
app.api.get('directory-admin/edges'),
|
|
// Access counts are a nicety, not load-bearing: if the LDAP join fails
|
|
// the table still renders, just without the Access column populated.
|
|
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; })
|
|
]);
|
|
|
|
accessSummary = (resAccess && resAccess.results) || {};
|
|
resourcesById = {};
|
|
|
|
for (const r of resResources.results) {
|
|
r.metadata = r.metadata || {};
|
|
resourcesById[r.id] = r;
|
|
}
|
|
|
|
allGroups = resGroups.results;
|
|
allEdges = resEdges.results;
|
|
|
|
rawResources = [];
|
|
for (const r of resResources.results) {
|
|
// Compute hostName from edges
|
|
r.hostName = '—';
|
|
r.parentId = null;
|
|
const parentEdge = allEdges.find(e => e.childId === r.id);
|
|
if (parentEdge) {
|
|
r.parentId = parentEdge.parentId;
|
|
const parent = resourcesById[parentEdge.parentId];
|
|
if (parent) r.hostName = parent.name;
|
|
}
|
|
rawResources.push(r);
|
|
}
|
|
|
|
renderTable();
|
|
|
|
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
|
|
// input accepts a free-typed uid whether or not the list ever arrives.
|
|
loadDirectoryUsers().then(function(users) {
|
|
$('#access-uid-list').html(users.map(function(u) {
|
|
return '<option value="' + u.uid + '">' + (u.cn || u.uid) + '</option>';
|
|
}).join(''));
|
|
}).catch(function(){ /* datalist is a convenience only */ });
|
|
} catch (err) {
|
|
console.error(err);
|
|
app.messages.toast('Failed to load data', 'danger');
|
|
}
|
|
}
|
|
|
|
// "Who can reach this?" at a glance. A resource with no linked group is not a
|
|
// locked-down resource -- it is an unreachable one, and a group whose LDAP
|
|
// entry has been deleted grants nothing, so both get called out rather than
|
|
// rendering as a quiet zero.
|
|
function accessCellHtml(resourceId) {
|
|
const summary = accessSummary[resourceId];
|
|
if (!summary || !summary.groups.length) {
|
|
return '<span class="badge bg-light text-dark border" title="No LDAP group gates this resource">no groups</span>';
|
|
}
|
|
const dangling = summary.groups.filter(g => !g.exists);
|
|
let html = '<span class="badge bg-primary" title="' +
|
|
summary.groups.map(g => g.cn + ' (' + g.accessLevel + ', ' + g.memberCount + ')').join(' ') +
|
|
'"><i class="fa-solid fa-user-group"></i> ' + summary.memberCount + '</span>';
|
|
html += ' <small class="text-muted">' + summary.groups.length +
|
|
' group' + (summary.groups.length === 1 ? '' : 's') + '</small>';
|
|
if (dangling.length) {
|
|
html += ' <span class="badge bg-warning text-dark" title="Linked group missing in LDAP: ' +
|
|
dangling.map(g => g.cn).join(', ') + '">!</span>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
// Reverse lookup: what can a given user reach? The question an access-control
|
|
// pane must answer and the directory previously could not -- /api/discovery/me
|
|
// only ever answered it for the caller themselves.
|
|
async function openUserAccessModal() {
|
|
const uid = ($('#user-access-uid').val() || '').trim();
|
|
if (!uid) return;
|
|
app.modal.open({ title: 'Access for ' + uid, bodyHtml: '<p class="text-muted">Loading…</p>' });
|
|
try {
|
|
const res = await app.api.get('directory-admin/user-access/' + encodeURIComponent(uid));
|
|
const data = res.results;
|
|
let html;
|
|
if (!data.resources.length) {
|
|
html = '<p class="text-muted">This user is in ' + data.groups.length +
|
|
' group(s), none of which gate a directory resource.</p>';
|
|
} else {
|
|
html = '<table class="table table-sm"><thead><tr><th>Resource</th><th>Kind</th>' +
|
|
'<th>Via group</th><th>Level</th></tr></thead><tbody>' +
|
|
data.resources.map(function(r) {
|
|
return '<tr><td><a href="/directory/' + encodeURIComponent(r.slug) + '">' + r.name + '</a></td>' +
|
|
'<td><span class="badge bg-secondary">' + r.kind + '</span></td>' +
|
|
'<td><code>' + r.groupCn + '</code></td>' +
|
|
'<td>' + r.accessLevel + '</td></tr>';
|
|
}).join('') + '</tbody></table>';
|
|
}
|
|
app.modal.open({ title: 'Access for ' + data.uid, bodyHtml: html, size: 'lg' });
|
|
} catch (err) {
|
|
app.modal.open({
|
|
title: 'Access for ' + uid,
|
|
bodyHtml: '<p class="text-danger">' + ((err && err.message) || 'Lookup failed') + '</p>'
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderTable() {
|
|
const filter = $('#search-filter').val().toLowerCase();
|
|
const sort = $('#sort-by').val();
|
|
|
|
let filtered = rawResources.filter(r => {
|
|
if (!filter) return true;
|
|
return (r.name || '').toLowerCase().includes(filter) ||
|
|
(r.slug || '').toLowerCase().includes(filter) ||
|
|
(r.kind || '').toLowerCase().includes(filter) ||
|
|
(r.metadata?.subType || '').toLowerCase().includes(filter) ||
|
|
(r.metadata?.ip || '').toLowerCase().includes(filter) ||
|
|
(r.hostName || '').toLowerCase().includes(filter);
|
|
});
|
|
|
|
// Sort
|
|
filtered.sort((a, b) => {
|
|
if (sort === 'name') return a.name.localeCompare(b.name);
|
|
if (sort === 'kind') return a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
|
|
if (sort === 'env') {
|
|
const ae = a.metadata?.isProduction ? 0 : 1;
|
|
const be = b.metadata?.isProduction ? 0 : 1;
|
|
return ae - be || a.name.localeCompare(b.name);
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
let finalRenderList = [];
|
|
|
|
const map = {};
|
|
const roots = [];
|
|
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
|
|
|
|
filtered.forEach(r => {
|
|
const node = map[r.id];
|
|
if (node.parentId && map[node.parentId]) {
|
|
map[node.parentId].children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
|
|
const flatten = (nodes, depth) => {
|
|
nodes.forEach(n => {
|
|
let indentHtml = '';
|
|
for(let i = 0; i < depth; i++) {
|
|
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
|
|
}
|
|
if (depth > 0) {
|
|
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
|
}
|
|
n.indentHtml = indentHtml;
|
|
n.accessHtml = accessCellHtml(n.id);
|
|
finalRenderList.push(n);
|
|
if (n.children.length > 0) {
|
|
flatten(n.children, depth + 1);
|
|
}
|
|
});
|
|
};
|
|
|
|
flatten(roots, 0);
|
|
|
|
$.scope.resources.empty();
|
|
for (const r of finalRenderList) {
|
|
$.scope.resources.push(r);
|
|
}
|
|
}
|
|
|
|
function toggleFormFields() {
|
|
const kind = $('#res-kind').val();
|
|
if (kind === 'host') {
|
|
$('#host-parent-container').show();
|
|
$('#host-details-container').show();
|
|
$('#service-ports-container').hide();
|
|
$('#service-details-container').hide();
|
|
$('#oauth-details-container').hide();
|
|
$('#external-container').hide();
|
|
$('#public-container').hide();
|
|
$('#site-details-container').hide();
|
|
} else if (kind === 'service') {
|
|
$('#host-parent-container').show();
|
|
$('#host-details-container').hide();
|
|
$('#service-ports-container').show();
|
|
$('#service-details-container').show();
|
|
$('#oauth-details-container').hide();
|
|
$('#external-container').show();
|
|
$('#public-container').show();
|
|
$('#site-details-container').hide();
|
|
} else if (kind === 'oauth') {
|
|
$('#host-parent-container').show();
|
|
$('#host-details-container').hide();
|
|
$('#service-ports-container').hide();
|
|
$('#service-details-container').hide();
|
|
$('#oauth-details-container').show();
|
|
$('#external-container').hide();
|
|
$('#public-container').hide();
|
|
$('#site-details-container').hide();
|
|
} else { // site
|
|
$('#host-parent-container').hide();
|
|
$('#host-details-container').hide();
|
|
$('#service-ports-container').hide();
|
|
$('#service-details-container').hide();
|
|
$('#oauth-details-container').hide();
|
|
$('#external-container').hide();
|
|
$('#public-container').hide();
|
|
$('#site-details-container').show();
|
|
}
|
|
populateHostDropdown($('#res-host-id').val());
|
|
}
|
|
|
|
// Delegated (not direct) since app.modal.open() rebuilds the modal body
|
|
// from scratch every call -- a direct .on() bind here would silently stop
|
|
// firing after the first Add/Edit.
|
|
app.modal.on('input change', '#res-name, #res-kind', function() {
|
|
const id = $('#res-id').val();
|
|
if (!id && $('#res-name').val()) {
|
|
const name = $('#res-name').val();
|
|
const kind = $('#res-kind').val();
|
|
let prefix = '';
|
|
if (kind === 'service') prefix = 'app_';
|
|
if (kind === 'host') prefix = 'host_';
|
|
if (kind === 'site') prefix = 'site_';
|
|
|
|
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
|
$('#res-slug').val(slug);
|
|
}
|
|
});
|
|
|
|
// Per-kind fallbacks, kept in sync with KIND_ICONS in views/landing.ejs so the
|
|
// preview shows what the catalog will actually render for a blank icon.
|
|
var KIND_ICON_FALLBACK = {
|
|
site: 'fa-solid fa-city',
|
|
host: 'fa-solid fa-server',
|
|
service: 'fa-solid fa-cube',
|
|
oauth: 'fa-solid fa-key'
|
|
};
|
|
|
|
function updateIconPreview() {
|
|
const raw = ($('#res-icon').val() || '').trim();
|
|
const cls = raw || KIND_ICON_FALLBACK[$('#res-kind').val()] || 'fa-solid fa-cube';
|
|
// Replace the class wholesale rather than toggling: a typed class is
|
|
// arbitrary user input and there is no reliable way to remove "whatever was
|
|
// there before" incrementally.
|
|
$('#res-icon-preview').attr('class', cls);
|
|
}
|
|
|
|
app.modal.on('input change', '#res-icon, #res-kind', updateIconPreview);
|
|
|
|
function openAddModal(parentId, parentKind) {
|
|
openResourceModal('Add Resource', null);
|
|
$('#res-id').val('');
|
|
$('#res-name').val('');
|
|
$('#res-slug').val('');
|
|
|
|
let defaultKind = 'service';
|
|
if (parentKind === 'site') defaultKind = 'host';
|
|
if (parentKind === 'host') defaultKind = 'service';
|
|
$('#res-kind').val(defaultKind);
|
|
|
|
if (!parentId && defaultKind === 'service') {
|
|
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
|
if (currentSite) parentId = currentSite.id;
|
|
}
|
|
if (!parentId && defaultKind === 'host') {
|
|
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
|
if (currentSite) parentId = currentSite.id;
|
|
}
|
|
|
|
$('#res-description').val('');
|
|
$('#res-ip').val('');
|
|
$('#res-address').val('');
|
|
$('#res-subtype').val('');
|
|
$('#res-vmid').val('');
|
|
$('#res-mac').val('');
|
|
$('#res-os').val('');
|
|
$('#res-port').val('');
|
|
$('#res-external-port').val('');
|
|
$('#res-git-repo').val('');
|
|
$('#res-install-path').val('');
|
|
$('#res-systemd').val('');
|
|
$('#res-icon').val('');
|
|
$('#res-tagline').val('');
|
|
updateIconPreview();
|
|
$('#res-is-production').prop('checked', false);
|
|
$('#res-is-external').prop('checked', false);
|
|
$('#res-is-public').prop('checked', false);
|
|
$('#res-is-current-site').prop('checked', false);
|
|
|
|
populateHostDropdown(parentId || '');
|
|
toggleFormFields();
|
|
}
|
|
|
|
var ldapGroupsCache = null;
|
|
async function loadLdapGroups() {
|
|
try {
|
|
if (!ldapGroupsCache) {
|
|
const res = await app.api.get('group');
|
|
ldapGroupsCache = res.results;
|
|
}
|
|
// Re-populate every call, not just the first -- #ldap-groups-datalist
|
|
// is rebuilt fresh (empty) on every app.modal.open(), so returning
|
|
// early here on a cache hit left the second and later modal opens
|
|
// with no autocomplete options at all.
|
|
const $datalist = $('#ldap-groups-datalist');
|
|
$datalist.empty();
|
|
for (const cn of ldapGroupsCache) {
|
|
$datalist.append($('<option>').val(cn));
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load LDAP groups', err);
|
|
}
|
|
}
|
|
|
|
function populateHostDropdown(selectedId) {
|
|
const kind = $('#res-kind').val();
|
|
const $target = $('#res-host-id');
|
|
$target.empty().append('<option value="">-- Select Parent --</option>');
|
|
Object.values(resourcesById).forEach(r => {
|
|
if (r.id === $('#res-id').val()) return; // cannot be parent of itself
|
|
|
|
if (kind === 'host' && (r.kind === 'site' || r.kind === 'host')) {
|
|
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
|
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
|
|
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
|
} else if (kind === 'oauth' && r.kind === 'service') {
|
|
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
|
}
|
|
});
|
|
if (selectedId) $target.val(selectedId);
|
|
}
|
|
|
|
// Plain DOM rendering, not jq-repeat: #groups-list is injected fresh into
|
|
// the modal body on every app.modal.open() call, and jq-repeat's
|
|
// MutationObserver-based (re)registration for a same-id element that's
|
|
// destroyed and recreated runs asynchronously -- populating it
|
|
// synchronously right after open() (as this function is called) would race
|
|
// that and silently lose the pushed data on the second and later opens.
|
|
// uid from a member/owner DN, e.g. "cn=jdoe,ou=people,..." -> "jdoe".
|
|
function uidFromDn(dn) {
|
|
const m = dn.match(/cn=[a-zA-Z0-9_\-@.]+/);
|
|
return m ? m[0].replace('cn=', '') : dn;
|
|
}
|
|
|
|
var directoryUserCache = null;
|
|
async function loadDirectoryUsers() {
|
|
if (!directoryUserCache) {
|
|
const data = await app.user.list();
|
|
directoryUserCache = data.results;
|
|
}
|
|
return directoryUserCache;
|
|
}
|
|
|
|
// Renders one associated group's member/owner management block: a
|
|
// collapsible member list (remove button per member) plus an "add member"
|
|
// dropdown of users not already in the group. Fetches the live LDAP group
|
|
// detail (member/owner DN arrays) rather than relying on `allGroups`, which
|
|
// only holds the resource<->group association record, not membership.
|
|
async function renderGroupMembership(g, resourceId) {
|
|
const $li = $('<li class="list-group-item"></li>');
|
|
const $head = $('<div class="d-flex justify-content-between align-items-center"></div>');
|
|
const $span = $('<span></span>');
|
|
$span.append('<i class="fa-solid fa-users text-muted me-2"></i>');
|
|
$('<strong></strong>').text(g.groupCn).appendTo($span);
|
|
$('<span class="badge bg-primary ms-2"></span>').text(g.accessLevel).appendTo($span);
|
|
$head.append($span);
|
|
|
|
const collapseId = 'group-members-' + g.id;
|
|
const $actions = $('<span></span>');
|
|
$('<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-bs-toggle="collapse"><i class="fa-solid fa-user-group"></i> Members</button>')
|
|
.attr('data-bs-target', '#' + collapseId)
|
|
.appendTo($actions);
|
|
$('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
|
|
.on('click', function(){ removeGroup(g.id); })
|
|
.appendTo($actions);
|
|
$head.append($actions);
|
|
$li.append($head);
|
|
|
|
const $collapse = $('<div class="collapse mt-2"></div>').attr('id', collapseId);
|
|
$li.append($collapse);
|
|
|
|
let detail, userlist;
|
|
try {
|
|
[detail, userlist] = await Promise.all([
|
|
app.group.get(g.groupCn).then(d => d.results),
|
|
loadDirectoryUsers(),
|
|
]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
$collapse.append('<p class="text-danger small mb-0">Failed to load group membership.</p>');
|
|
return $li;
|
|
}
|
|
|
|
const memberDns = Array.isArray(detail.member) ? detail.member : (detail.member ? [detail.member] : []);
|
|
const $memberList = $('<ul class="list-group list-group-flush mb-2"></ul>');
|
|
if (!memberDns.length) {
|
|
$memberList.append('<li class="list-group-item text-muted px-0">No members.</li>');
|
|
}
|
|
for (const dn of memberDns) {
|
|
const uid = uidFromDn(dn);
|
|
const $mLi = $('<li class="list-group-item d-flex justify-content-between align-items-center px-0"></li>');
|
|
$('<span></span>').append('<i class="fa-solid fa-user me-1"></i>').append(document.createTextNode(uid)).appendTo($mLi);
|
|
$('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-user-slash"></i></button>')
|
|
.on('click', function(){ removeResourceGroupMember(g.groupCn, uid, resourceId); })
|
|
.appendTo($mLi);
|
|
$memberList.append($mLi);
|
|
}
|
|
$collapse.append($memberList);
|
|
|
|
const toAdd = userlist.filter(function(u){ return !memberDns.includes(u.dn); });
|
|
const $dropdown = $('<div class="dropdown"></div>');
|
|
$('<button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown"><i class="fa-solid fa-user-plus"></i> Add member</button>')
|
|
.appendTo($dropdown);
|
|
const $menu = $('<div class="dropdown-menu shadow-lg" style="max-height: 240px; overflow-y: auto;"></div>');
|
|
for (const u of toAdd) {
|
|
$('<a class="dropdown-item" href="#"></a>').text(u.uid)
|
|
.on('click', function(e){ e.preventDefault(); addResourceGroupMember(g.groupCn, u.uid, resourceId); })
|
|
.appendTo($menu);
|
|
}
|
|
$dropdown.append($menu);
|
|
$collapse.append($dropdown);
|
|
|
|
return $li;
|
|
}
|
|
|
|
async function refreshGroupsUI(resourceId) {
|
|
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
|
const $list = $('#groups-list').empty();
|
|
if (!myGroups.length) {
|
|
$list.append('<li class="list-group-item text-muted">No associated groups.</li>');
|
|
return;
|
|
}
|
|
for (const g of myGroups) {
|
|
$list.append(await renderGroupMembership(g, resourceId));
|
|
}
|
|
}
|
|
|
|
async function addResourceGroupMember(groupCn, uid, resourceId) {
|
|
try {
|
|
const data = await app.api.put('group/' + groupCn + '/' + uid, {});
|
|
await refreshGroupsUI(resourceId);
|
|
app.messages.action(data.message, $('#groups-tab-container'), 'success');
|
|
app.util.revealItem($('#groups-list'));
|
|
} catch (err) {
|
|
app.messages.action((err && err.message) || 'Failed to add member', $('#groups-tab-container'), 'danger');
|
|
}
|
|
}
|
|
|
|
async function removeResourceGroupMember(groupCn, uid, resourceId) {
|
|
const confirmed = await app.messages.confirm('Remove "' + uid + '" from "' + groupCn + '"?', $('#groups-tab-container'), 'warning');
|
|
if (!confirmed) return;
|
|
try {
|
|
const data = await app.api.delete('group/' + groupCn + '/' + uid);
|
|
await refreshGroupsUI(resourceId);
|
|
app.messages.action(data.message, $('#groups-tab-container'), 'success');
|
|
} catch (err) {
|
|
app.messages.action((err && err.message) || 'Failed to remove member', $('#groups-tab-container'), 'danger');
|
|
}
|
|
}
|
|
|
|
function refreshEdgesUI(resourceId) {
|
|
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
|
const $list = $('#edges-list').empty();
|
|
if (!myEdges.length) {
|
|
$list.append('<li class="list-group-item text-muted">No other relationships.</li>');
|
|
}
|
|
for (const e of myEdges) {
|
|
const isParent = e.parentId === resourceId;
|
|
const targetId = isParent ? e.childId : e.parentId;
|
|
const target = resourcesById[targetId];
|
|
if (!target) continue;
|
|
|
|
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
|
const $span = $('<span></span>');
|
|
$span.append(isParent
|
|
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
|
|
: '<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: ');
|
|
$('<strong></strong>').text(target.name + ' (' + target.slug + ')').appendTo($span);
|
|
$('<span class="badge bg-secondary ms-1"></span>').text(e.relation).appendTo($span);
|
|
$li.append($span);
|
|
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
|
|
.on('click', function(){ removeEdge(e.id); })
|
|
.appendTo($li);
|
|
$list.append($li);
|
|
}
|
|
|
|
const $target = $('#new-edge-target');
|
|
$target.empty().append('<option value="">-- Select Resource --</option>');
|
|
Object.values(resourcesById).forEach(r => {
|
|
if (r.id !== resourceId) {
|
|
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
|
}
|
|
});
|
|
}
|
|
|
|
async function openEditModal(id) {
|
|
const r = resourcesById[id];
|
|
if (!r) return;
|
|
|
|
openResourceModal('Edit Resource', id);
|
|
$('#res-id').val(r.id);
|
|
$('#res-name').val(r.name);
|
|
$('#res-slug').val(r.slug);
|
|
$('#res-kind').val(r.kind);
|
|
$('#res-description').val(r.description || '');
|
|
$('#res-ip').val(r.metadata.ip || '');
|
|
$('#res-address').val(r.metadata.address || '');
|
|
$('#res-subtype').val(r.metadata.subType || '');
|
|
$('#res-vmid').val(r.metadata.vmid || '');
|
|
$('#res-mac').val(r.metadata.macAddress || '');
|
|
let osKernel = '';
|
|
if (r.metadata.os) osKernel += r.metadata.os;
|
|
if (r.metadata.kernel) osKernel += (osKernel ? ' / ' : '') + r.metadata.kernel;
|
|
$('#res-os').val(osKernel);
|
|
$('#res-port').val(r.metadata.port || '');
|
|
$('#res-external-port').val(r.metadata.externalPort || '');
|
|
$('#res-git-repo').val(r.metadata.gitRepo || '');
|
|
$('#res-install-path').val(r.metadata.installPath || '');
|
|
$('#res-systemd').val(r.metadata.systemdService || '');
|
|
$('#res-icon').val(r.metadata.icon || '');
|
|
$('#res-tagline').val(r.metadata.tagline || '');
|
|
updateIconPreview();
|
|
$('#res-is-production').prop('checked', !!r.metadata.isProduction);
|
|
$('#res-is-external').prop('checked', !!r.metadata.isExternalReachable);
|
|
$('#res-is-public').prop('checked', !!r.metadata.isPublic);
|
|
$('#res-is-current-site').prop('checked', !!r.metadata.isCurrentSite);
|
|
|
|
$('#res-redirect-uris').val((r.metadata.redirect_uris || []).join('\n'));
|
|
$('#res-scopes').val((r.metadata.scopes || []).join(' '));
|
|
$('#res-allowed-groups').val((r.metadata.allowed_groups || []).join(' '));
|
|
$('#res-access-ttl').val((r.metadata.token_lifetime || {}).access_token || 3600);
|
|
$('#res-refresh-ttl').val((r.metadata.token_lifetime || {}).refresh_token || 2592000);
|
|
|
|
if (r.kind === 'oauth') $('#oauth-rotate-container').show();
|
|
else $('#oauth-rotate-container').hide();
|
|
|
|
// Find parent host
|
|
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
|
|
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
|
toggleFormFields();
|
|
|
|
refreshGroupsUI(r.id);
|
|
refreshEdgesUI(r.id);
|
|
refreshChildrenUI(r.id);
|
|
await loadLdapGroups();
|
|
}
|
|
|
|
async function saveResource() {
|
|
const id = $('#res-id').val();
|
|
const data = {
|
|
name: $('#res-name').val(),
|
|
slug: $('#res-slug').val(),
|
|
kind: $('#res-kind').val(),
|
|
hostId: ['host', 'service', 'oauth'].includes($('#res-kind').val()) ? $('#res-host-id').val() : undefined,
|
|
description: $('#res-description').val(),
|
|
metadata: {
|
|
subType: $('#res-subtype').val(),
|
|
ip: $('#res-ip').val(),
|
|
address: $('#res-address').val(),
|
|
vmid: $('#res-vmid').val(),
|
|
macAddress: $('#res-mac').val(),
|
|
os: $('#res-os').val(),
|
|
port: $('#res-port').val(),
|
|
externalPort: $('#res-external-port').val() || $('#res-port').val(),
|
|
gitRepo: $('#res-git-repo').val(),
|
|
installPath: $('#res-install-path').val(),
|
|
systemdService: $('#res-systemd').val(),
|
|
icon: $('#res-icon').val().trim(),
|
|
tagline: $('#res-tagline').val().trim(),
|
|
isProduction: $('#res-is-production').is(':checked'),
|
|
isExternalReachable: $('#res-is-external').is(':checked'),
|
|
isPublic: $('#res-is-public').is(':checked'),
|
|
isCurrentSite: $('#res-is-current-site').is(':checked')
|
|
}
|
|
};
|
|
|
|
if (data.kind === 'oauth') {
|
|
data.redirect_uris = $('#res-redirect-uris').val().split('\n').map(x => x.trim()).filter(Boolean);
|
|
data.scopes = $('#res-scopes').val().split(' ').map(x => x.trim()).filter(Boolean);
|
|
data.allowed_groups = $('#res-allowed-groups').val().split(' ').map(x => x.trim()).filter(Boolean);
|
|
data.token_lifetime = {
|
|
access_token: Number($('#res-access-ttl').val()) || 3600,
|
|
refresh_token: Number($('#res-refresh-ttl').val()) || 2592000
|
|
};
|
|
}
|
|
|
|
try {
|
|
let res;
|
|
if (id) {
|
|
res = await app.api.put('directory-admin/resources/' + id, data);
|
|
} else {
|
|
res = await app.api.post('directory-admin/resources', data);
|
|
}
|
|
|
|
await loadResources();
|
|
const savedId = id || (res.results && res.results.id);
|
|
if (savedId) setTimeout(function(){ app.util.revealItem($('#resource-row-' + savedId)); }, 400);
|
|
|
|
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
|
|
// Deliberately no app.modal.close() before this -- app.modal is a
|
|
// singleton, and close() immediately followed by open() in the same
|
|
// tick collides with Bootstrap's hide-transition guard (show()
|
|
// silently no-ops while _isTransitioning is still true from the
|
|
// just-started hide()). open() alone already overwrites the
|
|
// (already-visible) modal's content in place. The await above made
|
|
// this race unlikely to lose in practice, but not guaranteed to.
|
|
app.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
|
|
} else {
|
|
app.modal.close();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
|
|
}
|
|
}
|
|
|
|
async function rotateSecret() {
|
|
const id = $('#res-id').val();
|
|
if (!id) return;
|
|
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', app.modal.body(), 'warning');
|
|
if (!ok) return;
|
|
|
|
try {
|
|
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
|
|
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
|
|
} catch (err) {
|
|
console.error(err);
|
|
app.messages.action(err.message || 'Failed to rotate secret', app.modal.body(), 'danger');
|
|
}
|
|
}
|
|
|
|
async function addGroup() {
|
|
const resourceId = $('#res-id').val();
|
|
const groupCn = $('#new-group-cn').val().trim();
|
|
const accessLevel = $('#new-group-level').val();
|
|
|
|
if (!groupCn) return app.messages.action('Group CN is required', app.modal.body(), 'danger');
|
|
try {
|
|
const res = await app.api.post('directory-admin/groups', {
|
|
resourceId,
|
|
groupCn,
|
|
accessLevel
|
|
});
|
|
allGroups.push(res.results);
|
|
refreshGroupsUI(resourceId);
|
|
$('#new-group-cn').val('');
|
|
} catch (err) {
|
|
console.error(err);
|
|
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
|
}
|
|
}
|
|
|
|
async function removeGroup(id) {
|
|
try {
|
|
await app.api.delete('directory-admin/groups/' + id);
|
|
allGroups = allGroups.filter(g => g.id !== id);
|
|
refreshGroupsUI($('#res-id').val());
|
|
} catch (err) {
|
|
console.error(err);
|
|
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
|
}
|
|
}
|
|
|
|
async function addEdge() {
|
|
const resourceId = $('#res-id').val();
|
|
const dir = $('#new-edge-dir').val();
|
|
const targetId = $('#new-edge-target').val();
|
|
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
|
|
|
if (!targetId) return app.messages.action('Select a target resource', app.modal.body(), 'danger');
|
|
|
|
const data = { relation };
|
|
if (dir === 'parent') {
|
|
data.parentId = resourceId;
|
|
data.childId = targetId;
|
|
} else {
|
|
data.parentId = targetId;
|
|
data.childId = resourceId;
|
|
}
|
|
|
|
try {
|
|
const res = await app.api.post('directory-admin/edges', data);
|
|
allEdges.push(res.results);
|
|
refreshEdgesUI(resourceId);
|
|
$('#new-edge-target').val('');
|
|
} catch (err) {
|
|
console.error(err);
|
|
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
|
}
|
|
}
|
|
|
|
async function removeEdge(id) {
|
|
try {
|
|
await app.api.delete('directory-admin/edges/' + id);
|
|
allEdges = allEdges.filter(e => e.id !== id);
|
|
refreshEdgesUI($('#res-id').val());
|
|
} catch (err) {
|
|
console.error(err);
|
|
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
|
}
|
|
}
|
|
|
|
async function deleteResource(id) {
|
|
// Called from the outer table's row button, not from inside the resource
|
|
// modal — target the page's own card so the confirm/error renders
|
|
// somewhere actually visible.
|
|
const $target = $('#resources-list');
|
|
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
|
|
if (!ok) return;
|
|
try {
|
|
await app.api.delete('directory-admin/resources/' + id);
|
|
await loadResources();
|
|
} catch (err) {
|
|
console.error(err);
|
|
app.messages.action('Failed to delete', $target, 'danger');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<%- include('bottom') %>
|