ecc9b62842
- Profile page: password reset as modal, tabs for My Groups/My Services/Security/Members - Catalog page: remove portal banner, split My Access into Services/Hosts tabs - Users list: fix double checkmark for users with multiple SSH keys - Directory page: remove parent badge and slug, align names with badges
372 lines
16 KiB
Plaintext
372 lines
16 KiB
Plaintext
<%- include('top') %>
|
|
|
|
<style>
|
|
.catalog-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 1.25rem;
|
|
}
|
|
.service-card {
|
|
height: 100%;
|
|
transition: transform .15s, box-shadow .15s;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.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;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.card-icon-img {
|
|
width: 1.8rem;
|
|
height: 1.8rem;
|
|
object-fit: contain;
|
|
}
|
|
.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="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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<!-- My Access section with tabs -->
|
|
<div class="card shadow mb-4">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="fa-solid fa-layer-group text-success"></i> My Access</span>
|
|
</div>
|
|
<div class="px-3 pt-3 border-bottom">
|
|
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#my-services-tab" type="button" role="tab">
|
|
<i class="fa-solid fa-cube"></i> Services
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#my-hosts-tab" type="button" role="tab">
|
|
<i class="fa-solid fa-server"></i> Hosts
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="tab-content">
|
|
<div class="tab-pane fade show active" id="my-services-tab" role="tabpanel">
|
|
<div class="catalog-grid" id="my-services"></div>
|
|
</div>
|
|
<div class="tab-pane fade" id="my-hosts-tab" role="tabpanel">
|
|
<div class="catalog-grid" id="my-hosts"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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();
|
|
|
|
// 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];
|
|
});
|
|
}
|
|
|
|
// Render icon as either Font Awesome class or <img> for URL
|
|
function renderIcon(icon, kind) {
|
|
if (!icon) icon = KIND_ICONS[kind] || 'fa-solid fa-cube';
|
|
// If it starts with http, treat it as an image URL
|
|
if (/^https?:\/\//i.test(icon)) {
|
|
return '<img src="' + esc(icon) + '" alt="" class="card-icon-img">';
|
|
}
|
|
// Otherwise it's a Font Awesome class
|
|
return '<i class="' + esc(icon) + ' card-icon"></i>';
|
|
}
|
|
|
|
// "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 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>';
|
|
}
|
|
|
|
var iconHtml = renderIcon(md.icon, r.kind);
|
|
|
|
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">'
|
|
+ iconHtml
|
|
+ '<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() {
|
|
// Split mine into services and hosts
|
|
var myServices = state.mine.filter(function(r) { return r.kind === 'service' || r.kind === 'oauth'; });
|
|
var myHosts = state.mine.filter(function(r) { return r.kind === 'host'; });
|
|
|
|
renderGrid('my-services', myServices, true);
|
|
renderGrid('my-hosts', myHosts, 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") %>
|