Files
sso-manager-node/nodejs/views/landing.ejs
T

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 &amp; 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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' && (r.metadata && r.metadata.managed);
});
// 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") %>