Release 1.11.0: end-user catalog, access requests, nested groups
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>
This commit is contained in:
+137
-5
@@ -15,6 +15,13 @@
|
||||
<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>
|
||||
@@ -31,6 +38,7 @@
|
||||
<tr>
|
||||
<th class="ps-3">Resource</th>
|
||||
<th>IP / Address</th>
|
||||
<th>Access</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -52,6 +60,7 @@
|
||||
{{#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>
|
||||
@@ -135,6 +144,31 @@
|
||||
<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 = `
|
||||
@@ -340,7 +374,9 @@
|
||||
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');
|
||||
@@ -352,12 +388,16 @@
|
||||
|
||||
async function loadResources() {
|
||||
try {
|
||||
const [resResources, resGroups, resEdges] = await Promise.all([
|
||||
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')
|
||||
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) {
|
||||
@@ -383,12 +423,75 @@
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -440,6 +543,7 @@
|
||||
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);
|
||||
@@ -515,6 +619,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 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('');
|
||||
@@ -547,6 +671,9 @@
|
||||
$('#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);
|
||||
@@ -782,6 +909,9 @@
|
||||
$('#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);
|
||||
@@ -827,6 +957,8 @@
|
||||
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'),
|
||||
|
||||
@@ -4,9 +4,38 @@
|
||||
var userlist;
|
||||
var allGroups = [];
|
||||
|
||||
// A member DN under the groups base is a nested group, not a person. Both
|
||||
// live in the same `member` attribute, so they have to be told apart here --
|
||||
// otherwise a nested group renders as a user whose name happens to be the
|
||||
// group's, and its remove button calls the user endpoint and 404s.
|
||||
function isGroupDn(dn){
|
||||
return /,ou=groups,/i.test(String(dn));
|
||||
}
|
||||
|
||||
function processGroup(value){
|
||||
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
||||
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
|
||||
|
||||
// Split before anything else consumes `member`.
|
||||
value.nested = value.member.filter(isGroupDn).map(function(dn){
|
||||
return {
|
||||
dn: dn,
|
||||
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
|
||||
groupCN: value.cn
|
||||
};
|
||||
});
|
||||
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
|
||||
value.nestedCount = value.nested.length;
|
||||
value.hasNested = value.nestedCount > 0;
|
||||
|
||||
// Candidates to nest: every other group not already nested here. Self is
|
||||
// excluded; deeper loops are refused server-side by Group.wouldCycle,
|
||||
// which is the only place that can see the whole graph.
|
||||
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
|
||||
value.toNest = allGroups.filter(function(g){
|
||||
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
|
||||
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
|
||||
|
||||
value.toAdd = userlist.filter(function(user){
|
||||
return !value.member.includes(user.dn);
|
||||
});
|
||||
@@ -91,11 +120,51 @@
|
||||
|
||||
async function tableAJAX(revealCn) {
|
||||
let data = await app.group.list();
|
||||
// processGroup builds each card's "nest a group" list from allGroups, so
|
||||
// it has to see the full set before the map runs -- assigning only the
|
||||
// mapped result would leave every dropdown empty on first load (and one
|
||||
// render stale thereafter). The raw entries carry the cn/dn it needs.
|
||||
allGroups = data.results;
|
||||
allGroups = data.results.map(processGroup);
|
||||
applyFilters();
|
||||
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
||||
}
|
||||
|
||||
function addNestedClick(event, groupCN, childCN, el){
|
||||
event.preventDefault();
|
||||
const $card = $('#group-card-' + groupCN);
|
||||
(async function(){
|
||||
try {
|
||||
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.messages.action(data.message, $card, 'success');
|
||||
} catch(e) {
|
||||
// 409 here is the cycle guard or an already-nested group -- both
|
||||
// carry a specific server message worth showing verbatim.
|
||||
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function removeNested(groupCN, childCN, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$item.addClass('list-group-item-warning');
|
||||
const confirmed = await app.messages.confirm(
|
||||
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
|
||||
$item, 'warning');
|
||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||
} catch(e) {
|
||||
$item.removeClass('list-group-item-warning');
|
||||
app.messages.action(e.message || 'Failed to un-nest group', $('#group-card-' + groupCN), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(groupCN, uid, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$item.addClass('list-group-item-warning');
|
||||
@@ -203,6 +272,12 @@
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="group-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
|
||||
<i class="fa-solid fa-user-tie"></i>
|
||||
@@ -249,6 +324,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="group-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
|
||||
<p class="text-muted small mb-2">
|
||||
Everyone in a nested group is a member of this one, at any depth.
|
||||
</p>
|
||||
<ul class="list-group">
|
||||
{{ #nested }}
|
||||
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
|
||||
<i class="fa-solid fa-layer-group"></i> {{ cn }}
|
||||
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
|
||||
<i class="fa-solid fa-link-slash"></i>
|
||||
</button>
|
||||
</li>
|
||||
{{ /nested }}
|
||||
{{ ^hasNested }}
|
||||
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
|
||||
{{ /hasNested }}
|
||||
</ul>
|
||||
<div class="dropdown mt-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-diagram-project"></i> Nest a group
|
||||
</button>
|
||||
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
|
||||
{{ #toNest }}
|
||||
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
|
||||
{{ /toNest }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
|
||||
<p>
|
||||
<ul class="list-group">
|
||||
|
||||
+296
-103
@@ -1,136 +1,329 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<style>
|
||||
/* App Portal styling using Bootstrap defaults */
|
||||
.portal-banner {
|
||||
background-color: var(--bs-primary);
|
||||
color: white;
|
||||
padding: 3rem 1rem;
|
||||
padding: 2.5rem 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: .5rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.portal-banner h1 {
|
||||
font-weight: 700;
|
||||
}
|
||||
.carousel-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
scrollbar-width: thin;
|
||||
.portal-banner h1 { font-weight: 700; }
|
||||
.catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.service-card {
|
||||
min-width: 280px;
|
||||
height: 100%;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.service-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
|
||||
}
|
||||
.service-card .card-body {
|
||||
flex: 1;
|
||||
.service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
|
||||
.service-card .card-body { flex: 1; }
|
||||
.card-icon { font-size: 1.4rem; width: 1.8rem; text-align: center; }
|
||||
.howto code {
|
||||
display: block;
|
||||
background: var(--bs-tertiary-bg, #f1f3f5);
|
||||
color: var(--bs-body-color);
|
||||
padding: .4rem .6rem;
|
||||
border-radius: .25rem;
|
||||
font-size: .8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.empty-note { color: var(--bs-secondary-color, #6c757d); font-style: italic; }
|
||||
</style>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="portal-banner text-center">
|
||||
<h1>SSO Portal</h1>
|
||||
<p class="lead">Explore and access all your services in one place.</p>
|
||||
<a href="/profile" class="btn btn-light shadow-sm mt-2"><i class="fa-solid fa-user"></i> My Profile</a>
|
||||
<h1><%- name %> Portal</h1>
|
||||
<p class="lead mb-3">Everything the lab offers — what you can reach, and how to reach it.</p>
|
||||
<a href="/profile" class="btn btn-light shadow-sm"><i class="fa-solid fa-user"></i> My Profile</a>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-primary"></i> My Apps & Services</h3>
|
||||
<div class="carousel-container mb-5" id="my-services" jq-repeat="myservices">
|
||||
<a href="{{resolvedAddress}}" target="_blank" style="text-decoration: none; color: inherit; min-width: 280px;">
|
||||
<div class="card shadow-sm service-card border-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-success"><i class="fa-solid fa-rocket"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<span class="badge bg-success">Access Granted</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<input type="text" id="catalog-search" class="form-control shadow-sm"
|
||||
placeholder="Search services and hosts..." onkeyup="renderAll()">
|
||||
</div>
|
||||
<div class="col-md-4 mt-2 mt-md-0">
|
||||
<select id="catalog-kind" class="form-select shadow-sm" onchange="renderAll()">
|
||||
<option value="">All kinds</option>
|
||||
<option value="service">Services & apps</option>
|
||||
<option value="host">Hosts</option>
|
||||
<option value="site">Sites</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More Services</h3>
|
||||
<div class="carousel-container mb-5" id="other-services" jq-repeat="otherservices">
|
||||
<div class="card shadow-sm service-card" style="min-width: 280px;" onclick="requestAccess('{{id}}')">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa-solid fa-cloud"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<span class="badge bg-secondary">Request Access</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="my-requests-section" style="display:none;">
|
||||
<h3 class="mb-3"><i class="fa-solid fa-hourglass-half text-warning"></i> My Requests</h3>
|
||||
<ul class="list-group mb-5 shadow-sm" id="my-requests"></ul>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-server text-info"></i> Hosts & Infrastructure</h3>
|
||||
<div class="carousel-container mb-5" id="hosts" jq-repeat="hosts">
|
||||
<div class="card shadow-sm service-card" style="min-width: 280px;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><i class="fa-solid fa-desktop"></i> {{name}}</h5>
|
||||
<p class="card-text text-muted mb-1">IP: {{metadata.ip}}</p>
|
||||
<p class="card-text small mb-0">OS: {{metadata.os}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="approvals-section" style="display:none;">
|
||||
<h3 class="mb-3"><i class="fa-solid fa-user-check text-danger"></i> Awaiting My Approval</h3>
|
||||
<ul class="list-group mb-5 shadow-sm" id="approvals"></ul>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-success"></i> My Access</h3>
|
||||
<div class="catalog-grid mb-5" id="my-services"></div>
|
||||
|
||||
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3>
|
||||
<p class="text-muted small">Things you don't have access to yet. Request what you need.</p>
|
||||
<div class="catalog-grid mb-5" id="other-services"></div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin();
|
||||
|
||||
$(document).ready(async function() {
|
||||
try {
|
||||
let res = await app.api.get('discovery/me');
|
||||
let allAccessible = res.results || [];
|
||||
|
||||
let allRes = await app.api.get('directory-admin/resources').catch(e => { return {results:[]}; });
|
||||
|
||||
let myServices = [];
|
||||
let otherServices = [];
|
||||
let hosts = [];
|
||||
|
||||
allAccessible.forEach(r => {
|
||||
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || '#';
|
||||
r.description = r.description || 'No description provided';
|
||||
if (r.kind === 'service' || r.kind === 'oauth') myServices.push(r);
|
||||
if (r.kind === 'host') hosts.push(r);
|
||||
});
|
||||
|
||||
if (allRes && allRes.results) {
|
||||
allRes.results.forEach(r => {
|
||||
r.description = r.description || 'No description provided';
|
||||
if (r.kind === 'service' && !myServices.find(s => s.id === r.id)) {
|
||||
otherServices.push(r);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$.scope.myservices.empty();
|
||||
$.scope.myservices.push(...myServices);
|
||||
|
||||
$.scope.otherservices.empty();
|
||||
$.scope.otherservices.push(...otherServices);
|
||||
|
||||
$.scope.hosts.empty();
|
||||
$.scope.hosts.push(...hosts);
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load discovery data:', e);
|
||||
}
|
||||
});
|
||||
|
||||
function requestAccess(id) {
|
||||
app.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
|
||||
|
||||
// Connection conventions from conf/base.js `directory`, injected server-side
|
||||
// so the "how to reach this" block renders the invocation that actually
|
||||
// works in this deployment rather than a guess.
|
||||
var DIRECTORY_CONF = <%- JSON.stringify(directoryConf) %>;
|
||||
|
||||
var state = { mine: [], others: [], requests: [], approvals: [], uid: null };
|
||||
|
||||
var KIND_ICONS = {
|
||||
site: 'fa-solid fa-city',
|
||||
host: 'fa-solid fa-server',
|
||||
service: 'fa-solid fa-cube',
|
||||
oauth: 'fa-solid fa-key'
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||
});
|
||||
}
|
||||
|
||||
// "How do I actually use this?" — the question the directory exists to
|
||||
// answer and the one the old portal never did. Everything here is derived
|
||||
// from directory metadata; nothing is hardcoded per-service.
|
||||
function howTo(r) {
|
||||
var md = r.metadata || {};
|
||||
var addr = r.resolvedAddress || md.address || md.ip;
|
||||
var lines = [];
|
||||
|
||||
if (r.kind === 'host') {
|
||||
var sshPort = md.sshPort || DIRECTORY_CONF.defaultSshPort;
|
||||
var portArg = String(sshPort) === '22' ? '' : ' -p ' + sshPort;
|
||||
if (DIRECTORY_CONF.jumpHost) {
|
||||
// The jump-host username grammar: one string, no interactive
|
||||
// menu, so it works in WinSCP/FileZilla as well as a terminal.
|
||||
lines.push('ssh ' + state.uid + '_-_' + r.slug + '@' + DIRECTORY_CONF.jumpHost + portArg);
|
||||
} else if (addr) {
|
||||
lines.push('ssh ' + state.uid + '@' + addr + portArg);
|
||||
}
|
||||
} else if (addr) {
|
||||
var isUrl = /^https?:\/\//i.test(addr);
|
||||
if (isUrl) {
|
||||
lines.push(addr);
|
||||
} else {
|
||||
var port = md.externalPort || md.port;
|
||||
lines.push(port ? 'https://' + addr + ':' + port : 'https://' + addr);
|
||||
}
|
||||
}
|
||||
|
||||
if (md.gitRepo) lines.push('Source: ' + md.gitRepo);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function linkFor(r) {
|
||||
var md = r.metadata || {};
|
||||
var addr = r.resolvedAddress || md.address || md.ip;
|
||||
if (!addr || r.kind === 'host') return null;
|
||||
if (/^https?:\/\//i.test(addr)) return addr;
|
||||
var port = md.externalPort || md.port;
|
||||
return port ? 'https://' + addr + ':' + port : 'https://' + addr;
|
||||
}
|
||||
|
||||
function cardHtml(r, accessible) {
|
||||
var md = r.metadata || {};
|
||||
var icon = md.icon || KIND_ICONS[r.kind] || 'fa-solid fa-cube';
|
||||
var blurb = md.tagline || r.description || 'No description provided';
|
||||
var href = accessible ? linkFor(r) : null;
|
||||
var lines = accessible ? howTo(r) : [];
|
||||
|
||||
var badges = '';
|
||||
if (md.isProduction) badges += '<span class="badge bg-danger ms-1">Prod</span>';
|
||||
if (md.isExternalReachable) badges += '<span class="badge bg-info ms-1">External</span>';
|
||||
if (md.os) badges += '<span class="badge bg-light text-dark border ms-1">' + esc(md.os) + '</span>';
|
||||
|
||||
var footer;
|
||||
if (accessible) {
|
||||
footer = href
|
||||
? '<a class="btn btn-sm btn-success w-100" target="_blank" rel="noopener" href="' + esc(href) + '">Open <i class="fa-solid fa-arrow-up-right-from-square"></i></a>'
|
||||
: '<span class="badge bg-success">Access granted</span>';
|
||||
} else if (md.requestable === false) {
|
||||
footer = '<span class="badge bg-secondary">Not requestable</span>';
|
||||
} else if (state.requests.some(function(q){ return q.resourceId === r.id && q.status === 'pending'; })) {
|
||||
footer = '<span class="badge bg-warning text-dark">Request pending</span>';
|
||||
} else {
|
||||
footer = '<button class="btn btn-sm btn-outline-primary w-100" onclick="requestAccess(\'' + esc(r.id) + '\')">'
|
||||
+ '<i class="fa-solid fa-hand"></i> Request access</button>';
|
||||
}
|
||||
|
||||
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||
+ '<div class="card-body">'
|
||||
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||
+ '<i class="' + esc(icon) + ' card-icon ' + (accessible ? 'text-success' : 'text-secondary') + '"></i>'
|
||||
+ '<span>' + esc(r.name) + '</span>'
|
||||
+ '</h5>'
|
||||
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
||||
+ '<p class="card-text small text-muted">' + esc(blurb) + '</p>'
|
||||
+ (lines.length
|
||||
? '<div class="howto small"><div class="text-muted mb-1">How to reach it</div>'
|
||||
+ lines.map(function(l){ return '<code>' + esc(l) + '</code>'; }).join('')
|
||||
+ '</div>'
|
||||
: '')
|
||||
+ '</div>'
|
||||
+ '<div class="card-footer bg-transparent border-top-0">' + footer + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function matchesFilter(r) {
|
||||
var q = ($('#catalog-search').val() || '').toLowerCase();
|
||||
var kind = $('#catalog-kind').val() || '';
|
||||
if (kind && r.kind !== kind) return false;
|
||||
if (!q) return true;
|
||||
var md = r.metadata || {};
|
||||
return [r.name, r.slug, r.description, md.tagline, md.subType, md.ip, md.address]
|
||||
.filter(Boolean).join(' ').toLowerCase().indexOf(q) !== -1;
|
||||
}
|
||||
|
||||
function renderGrid(elId, list, accessible) {
|
||||
var items = list.filter(matchesFilter);
|
||||
var el = document.getElementById(elId);
|
||||
if (!items.length) {
|
||||
el.innerHTML = '<p class="empty-note">Nothing to show here.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = items.map(function(r){ return cardHtml(r, accessible); }).join('');
|
||||
}
|
||||
|
||||
function renderRequests() {
|
||||
var open = state.requests.filter(function(q){ return q.status === 'pending'; });
|
||||
document.getElementById('my-requests-section').style.display = open.length ? '' : 'none';
|
||||
document.getElementById('my-requests').innerHTML = open.map(function(q){
|
||||
var label = q.resource ? q.resource.name : q.groupCn;
|
||||
return '<li class="list-group-item d-flex justify-content-between align-items-center">'
|
||||
+ '<span><strong>' + esc(label) + '</strong> '
|
||||
+ '<small class="text-muted">via <code>' + esc(q.groupCn) + '</code></small></span>'
|
||||
+ '<button class="btn btn-sm btn-outline-danger" onclick="withdraw(\'' + esc(q.id) + '\')">Withdraw</button>'
|
||||
+ '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderApprovals() {
|
||||
document.getElementById('approvals-section').style.display = state.approvals.length ? '' : 'none';
|
||||
document.getElementById('approvals').innerHTML = state.approvals.map(function(q){
|
||||
var label = q.resource ? q.resource.name : q.groupCn;
|
||||
return '<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">'
|
||||
+ '<span><strong>' + esc(q.uid) + '</strong> requests <strong>' + esc(label) + '</strong> '
|
||||
+ '<small class="text-muted">(<code>' + esc(q.groupCn) + '</code>)</small>'
|
||||
+ (q.note ? '<br><small class="text-muted">' + esc(q.note) + '</small>' : '')
|
||||
+ '</span>'
|
||||
+ '<span class="d-flex gap-2">'
|
||||
+ '<button class="btn btn-sm btn-success" onclick="decide(\'' + esc(q.id) + '\',\'approve\')">Approve</button>'
|
||||
+ '<button class="btn btn-sm btn-outline-danger" onclick="decide(\'' + esc(q.id) + '\',\'deny\')">Deny</button>'
|
||||
+ '</span></li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderGrid('my-services', state.mine, true);
|
||||
renderGrid('other-services', state.others, false);
|
||||
renderRequests();
|
||||
renderApprovals();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
var me = await app.auth.asyncUser;
|
||||
state.uid = me.uid;
|
||||
|
||||
// Both endpoints are the *discovery* API, not directory-admin. The old
|
||||
// portal called directory-admin/resources and swallowed the 403, so
|
||||
// "Discover More" was permanently empty for every non-admin — i.e. for
|
||||
// exactly the people it was built for.
|
||||
var mineRes = await app.api.get('discovery/me');
|
||||
var allRes = await app.api.get('discovery/resources');
|
||||
|
||||
state.mine = (mineRes.results || []).filter(function(r){ return r.kind !== 'site'; });
|
||||
var mineIds = {};
|
||||
state.mine.forEach(function(r){ mineIds[r.id] = true; });
|
||||
state.others = (allRes.results || []).filter(function(r){
|
||||
return !mineIds[r.id] && r.kind !== 'site' && r.kind !== 'oauth';
|
||||
});
|
||||
|
||||
// Requests are best-effort: a failure here must not blank the catalog.
|
||||
try {
|
||||
var mineReq = await app.api.get('access-requests/mine');
|
||||
state.requests = mineReq.results || [];
|
||||
} catch (e) { state.requests = []; }
|
||||
try {
|
||||
var pending = await app.api.get('access-requests');
|
||||
state.approvals = pending.results || [];
|
||||
} catch (e) { state.approvals = []; }
|
||||
|
||||
renderAll();
|
||||
}
|
||||
|
||||
async function requestAccess(id) {
|
||||
var resource = state.others.find(function(r){ return r.id === id; });
|
||||
if (!resource) return;
|
||||
app.modal.open({
|
||||
title: 'Request access to ' + resource.name,
|
||||
bodyHtml: '<div class="actionMessage" style="display:none"></div>'
|
||||
+ '<p class="text-muted small">Your request goes to the resource owner for approval.</p>'
|
||||
+ '<label class="form-label">Why do you need it? <span class="text-muted">(optional)</span></label>'
|
||||
+ '<textarea id="req-note" class="form-control" rows="3"></textarea>',
|
||||
footer: {
|
||||
buttonsHtml: '<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
|
||||
+ '<button class="btn btn-primary ms-2" onclick="submitRequest(\'' + esc(id) + '\')">Send request</button>'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function submitRequest(id) {
|
||||
try {
|
||||
await app.api.post('access-requests', { resourceId: id, note: $('#req-note').val() });
|
||||
app.modal.close();
|
||||
app.messages.toast('Request sent', 'success');
|
||||
await load();
|
||||
} catch (err) {
|
||||
app.messages.action((err && err.message) || 'Could not send request', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function withdraw(id) {
|
||||
try {
|
||||
await app.api.delete('access-requests/' + id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
app.messages.toast((err && err.message) || 'Could not withdraw', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(id, action) {
|
||||
try {
|
||||
await app.api.post('access-requests/' + id + '/' + action, {});
|
||||
app.messages.toast('Request ' + (action === 'approve' ? 'approved' : 'denied'), 'success');
|
||||
await load();
|
||||
} catch (err) {
|
||||
app.messages.toast((err && err.message) || 'Could not update request', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
load().catch(function(e){
|
||||
console.error('Failed to load catalog:', e);
|
||||
app.messages.toast('Could not load the catalog', 'danger');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<%- include("bottom") %>
|
||||
|
||||
@@ -92,8 +92,14 @@
|
||||
async function renderMyServices(){
|
||||
try{
|
||||
let res = await app.api.get('discovery/me');
|
||||
// The server already resolves this by walking the graph, so a service
|
||||
// with no address of its own inherits its host's. Only fall back
|
||||
// locally when it genuinely resolved to nothing.
|
||||
res.results.forEach(r => {
|
||||
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || 'N/A';
|
||||
r.resolvedAddress = r.resolvedAddress
|
||||
|| (r.metadata && r.metadata.address)
|
||||
|| (r.metadata && r.metadata.ip)
|
||||
|| 'N/A';
|
||||
});
|
||||
$.scope.myservices.empty();
|
||||
$.scope.myservices.push(...res.results);
|
||||
|
||||
Reference in New Issue
Block a user