Files
sso-manager-node/nodejs/views/landing.ejs
T
wmantly a78db906e8 Fix SMTP From-address fallback rejection; catalog card icon order; doc corrections
Mail sending fell back to a hardcoded noreply@theta42.com From address when
smtp.from wasn't set, which authenticated relays reject with "Sender is not
same as SMTP authenticate username" since no relay authorized this account
to send as that address. Falls back to smtp.user first now.

Also: catalog card titles now read name-then-icon instead of icon-then-name,
and a handful of docs corrections found in an accuracy pass (configuration.md
missing the OpenBao/live-config layer, plugins.md undercounting plugin types,
vault.md describing OpenBao dev-mode/root-token access that doesn't reflect
the real production setup, orphaned discovery.md/vault.md pages linked in,
README's required-groups list missing app_sso_directory_admin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gCdnfSCuZr6xvPDxTo3D
2026-08-06 21:13:38 -04:00

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">'
+ '<span>' + esc(r.name) + '</span>'
+ iconHtml
+ '</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") %>