Files
sso-manager-node/nodejs/views/vault.ejs
T
wmantly b46b3bed80 fix: use app.messages.confirm instead of native confirm() in vault Shared tab
The no_native_dialogs regression test forbids native alert/confirm/prompt in
views (they block browser events). Replace the native confirm() in deleteShared
with app.messages.confirm().
2026-08-03 22:19:38 -04:00

569 lines
26 KiB
Plaintext

<%- include('top') %>
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h2>
<ul class="nav nav-pills" id="vault-tabs">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-shared" type="button">Shared</button></li>
</ul>
</div>
<div class="tab-content">
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-secrets">
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary" onclick="showCreateModal()">
<i class="fas fa-plus"></i> New Secret
</button>
</div>
<div class="row">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
<div class="list-group list-group-flush" id="secrets-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteCurrentSecret()"><i class="fas fa-trash"></i> Delete</button>
</div>
</div>
<div class="card-body">
<pre id="secret-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre>
</div>
</div>
<div id="no-secret-selected" class="text-center text-muted mt-5">
<i class="fas fa-key fa-4x mb-3 text-secondary"></i>
<h4>Select a secret to view its details</h4>
</div>
</div>
</div>
</div>
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
<div class="tab-pane fade" id="tab-apps">
<div class="row">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
<div class="card-body">
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/&lt;name&gt;/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
<div class="mb-3">
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
</div>
<button class="btn btn-primary" onclick="mintApp()"><i class="fas fa-key"></i> Mint token</button>
<div class="alert alert-danger d-none mt-3" id="app-error"></div>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card shadow-sm d-none" id="app-result-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">App token</h5>
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
</div>
<div class="card-body">
<p class="small text-muted">Give the external app this token (header <code>X-Vault-Token</code>) and the path convention below.</p>
<pre id="app-token" class="bg-dark text-light p-3 rounded"></pre>
<h6 class="mt-3">Connection convention</h6>
<pre class="bg-light p-2 rounded small">VAULT_ADDR=<%- vaultAddr %>
path=secret/apps/&lt;name&gt;/conf
curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf" \
-H "X-Vault-Token: &lt;token above&gt;"</pre>
</div>
</div>
</div>
</div>
</div>
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="tab-shared">
<div class="row">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">My shared secrets</h5>
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
</div>
<div class="list-group list-group-flush" id="shared-mine-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Shared with me</h5></div>
<div class="list-group list-group-flush" id="shared-granted-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Shared Secret Modal -->
<div class="modal fade" id="sharedCreateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Shared Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name (slug)</label>
<input type="text" class="form-control" id="shared-slug-input" placeholder="e.g. db-creds">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" id="shared-desc-input" placeholder="optional">
</div>
<div class="mb-3">
<label class="form-label">Secret Data (JSON)</label>
<textarea class="form-control" id="shared-data-input" rows="6" style="font-family: monospace;">{
"key": "value"
}</textarea>
</div>
<div class="alert alert-danger d-none" id="shared-create-error"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSharedSecret()">Create</button>
</div>
</div>
</div>
</div>
<!-- Manage Grants Modal -->
<div class="modal fade" id="sharedGrantsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Share</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-4"><select class="form-select" id="grant-type-input"><option value="user">User</option><option value="app">App</option></select></div>
<div class="col-5"><input class="form-control" id="grant-id-input" placeholder="uid or app name"></div>
<div class="col-3"><button class="btn btn-primary w-100" onclick="addGrant()">Grant</button></div>
</div>
<div class="alert alert-danger d-none" id="grants-error"></div>
<div class="list-group" id="grants-list"><div class="list-group-item text-muted">No grants yet.</div></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- View Shared Secret Modal -->
<div class="modal fade" id="sharedViewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="shared-view-title">Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body"><pre id="shared-view-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre></div>
</div>
</div>
</div>
<!-- Create/Edit Secret Modal -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="secretModalTitle">Create Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" id="secret-path-label">Secret name (in your personal namespace)</label>
<input type="text" class="form-control" id="secret-path-input" placeholder="e.g. database-creds">
</div>
<div class="mb-3">
<label class="form-label">Secret Data (JSON)</label>
<textarea class="form-control" id="secret-data-input" rows="8" style="font-family: monospace;">{
"username": "",
"password": ""
}</textarea>
</div>
<div class="alert alert-danger d-none" id="secret-error"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSecret()">Save Secret</button>
</div>
</div>
</div>
</div>
<script>
// Login gate + client-derived scoping. VAULT_BASE is '' for admins
// (free-form under secret/) or 'users/<uid>/' for everyone else (confined
// to their personal namespace). The /api/vault proxy enforces the same
// server-side (scopeGuard + the token's OpenBao policy), so this only
// drives the UI. Resolved in init() after forceLogin loads the user — the
// previous version read these server-side from req.user, which is undefined
// on a browser navigation (auth-token is a client-set header, not a cookie).
let VAULT_BASE = '';
let IS_ADMIN = false;
let currentSecretPath = null;
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
// Build a vault API path. kind is 'data' or 'metadata'; key is the logical
// key relative to the subject's namespace (so 'foo' for a user means
// secret/data/users/<uid>/foo).
function vpath(kind, key) {
let cleanKey = key || '';
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
if (VAULT_BASE) {
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
}
return `secret/${kind}/${cleanKey}`;
}
function apiCall(method, path, body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() }
};
if (body) opts.body = JSON.stringify(body);
return fetch('/api/vault/' + path, opts).then(async res => {
if (res.status === 404) return null;
if (!res.ok) {
const text = await res.text();
throw new Error(`Vault API error: ${res.status} ${text}`);
}
if (res.status === 204) return null;
return res.json();
});
}
async function loadSecrets() {
try {
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
const res = await apiCall('GET', listPath);
const listEl = document.getElementById('secrets-list');
listEl.innerHTML = '';
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
listEl.innerHTML = '<div class="list-group-item text-center text-muted">No secrets found</div>';
return;
}
res.data.keys.forEach(key => {
// KV list returns dir entries with a trailing slash; admins can still
// open them by typing the full path in the modal. Skip dirs in the list
// for non-admins (their namespace is flat).
if (!IS_ADMIN && key.endsWith('/')) return;
const item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action d-flex align-items-center';
item.innerHTML = `<i class="fas fa-file-alt text-secondary me-3"></i> <span>${key}</span>`;
item.onclick = (e) => {
e.preventDefault();
document.querySelectorAll('#secrets-list .active').forEach(el => el.classList.remove('active'));
item.classList.add('active');
loadSecretDetails(key);
};
listEl.appendChild(item);
});
} catch (err) {
document.getElementById('secrets-list').innerHTML =
`<div class="list-group-item text-danger"><i class="fas fa-exclamation-triangle"></i> Error loading secrets: ${err.message}</div>`;
}
}
async function loadSecretDetails(key) {
try {
currentSecretPath = key;
document.getElementById('no-secret-selected').style.display = 'none';
document.getElementById('secret-details-card').style.display = 'block';
document.getElementById('secret-title').textContent = key;
document.getElementById('secret-content').textContent = 'Loading...';
const res = await apiCall('GET', vpath('data', key));
if (!res || !res.data || !res.data.data) {
document.getElementById('secret-content').textContent = 'No data found.';
} else {
document.getElementById('secret-content').textContent = JSON.stringify(res.data.data, null, 2);
}
} catch (err) {
document.getElementById('secret-content').textContent = `Error: ${err.message}`;
}
}
function showCreateModal() {
currentSecretPath = null;
document.getElementById('secretModalTitle').textContent = 'Create Secret';
document.getElementById('secret-path-input').value = '';
document.getElementById('secret-path-input').disabled = false;
document.getElementById('secret-data-input').value = '{\n "key": "value"\n}';
document.getElementById('secret-error').classList.add('d-none');
secretModal.show();
}
function editCurrentSecret() {
if (!currentSecretPath) return;
document.getElementById('secretModalTitle').textContent = 'Edit Secret';
document.getElementById('secret-path-input').value = currentSecretPath;
document.getElementById('secret-path-input').disabled = true;
document.getElementById('secret-data-input').value = document.getElementById('secret-content').textContent;
document.getElementById('secret-error').classList.add('d-none');
secretModal.show();
}
async function saveSecret() {
const errorEl = document.getElementById('secret-error');
errorEl.classList.add('d-none');
const path = document.getElementById('secret-path-input').value.trim();
if (!path) {
errorEl.textContent = 'Secret path is required';
errorEl.classList.remove('d-none');
return;
}
let data;
try {
data = JSON.parse(document.getElementById('secret-data-input').value);
} catch (err) {
errorEl.textContent = 'Invalid JSON: ' + err.message;
errorEl.classList.remove('d-none');
return;
}
try {
await apiCall('POST', vpath('data', path), { data });
secretModal.hide();
await loadSecrets();
if (currentSecretPath === path || !currentSecretPath) {
await loadSecretDetails(path);
}
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
async function deleteCurrentSecret() {
if (!currentSecretPath) return;
try {
await apiCall('DELETE', vpath('metadata', currentSecretPath));
currentSecretPath = null;
document.getElementById('no-secret-selected').style.display = 'block';
document.getElementById('secret-details-card').style.display = 'none';
await loadSecrets();
} catch (err) {
app.messages.toast('Error deleting secret: ' + err.message, 'danger');
}
}
// ── Apps tab (admin) ───────────────────────────────────────────────────
async function mintApp() {
const errorEl = document.getElementById('app-error');
errorEl.classList.add('d-none');
const name = document.getElementById('app-name-input').value.trim();
if (!name) {
errorEl.textContent = 'App name is required';
errorEl.classList.remove('d-none');
return;
}
try {
const res = await fetch('/api/vault/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
body: JSON.stringify({ name })
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${text}`);
}
const result = await res.json();
document.getElementById('app-token').textContent = result.token;
document.getElementById('app-name-display').textContent = name;
document.getElementById('app-result-card').classList.remove('d-none');
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
}
// ── Shared secrets tab ──────────────────────────────────────────────
let currentShared = null;
const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal'));
const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal'));
const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal'));
function sharedApi(path, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } };
if (body) opts.body = JSON.stringify(body);
return fetch('/api/shared-secrets' + path, opts).then(async res => {
if (res.status === 404) return null;
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); }
if (res.status === 204) return null;
return res.json();
});
}
async function loadShared() {
try {
const res = await sharedApi('/');
const items = (res && res.items) || [];
renderSharedMine(items.filter(i => i.role === 'owner'));
renderSharedGranted(items.filter(i => i.role === 'grantee'));
} catch (err) {
document.getElementById('shared-mine-list').innerHTML =
`<div class="list-group-item text-danger">Error: ${err.message}</div>`;
}
}
function renderSharedMine(items) {
const el = document.getElementById('shared-mine-list');
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">No shared secrets yet</div>'; return; }
el.innerHTML = '';
items.forEach(s => {
const row = document.createElement('div');
row.className = 'list-group-item d-flex justify-content-between align-items-center';
row.innerHTML = `<div><i class="fas fa-share-alt text-secondary me-2"></i><strong>${s.slug}</strong><div class="small text-muted">${s.path}</div></div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="openGrants('${s.id}')"><i class="fas fa-users"></i> Share</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteShared('${s.id}')"><i class="fas fa-trash"></i></button>
</div>`;
el.appendChild(row);
});
}
function renderSharedGranted(items) {
const el = document.getElementById('shared-granted-list');
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">Nothing shared with you yet</div>'; return; }
el.innerHTML = '';
items.forEach(s => {
const row = document.createElement('a');
row.href = '#';
row.className = 'list-group-item list-group-item-action d-flex align-items-center';
row.innerHTML = `<i class="fas fa-key text-secondary me-3"></i><span>${s.slug}</span><small class="text-muted ms-auto">by ${s.ownerUid}</small>`;
row.onclick = (e) => { e.preventDefault(); viewShared(s); };
el.appendChild(row);
});
}
function showCreateSharedModal() {
currentShared = null;
document.getElementById('shared-slug-input').value = '';
document.getElementById('shared-desc-input').value = '';
document.getElementById('shared-data-input').value = '{\n "key": "value"\n}';
document.getElementById('shared-create-error').classList.add('d-none');
sharedCreateModal.show();
}
async function saveSharedSecret() {
const err = document.getElementById('shared-create-error');
err.classList.add('d-none');
let data;
try { data = JSON.parse(document.getElementById('shared-data-input').value); }
catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; }
try {
await sharedApi('/', 'POST', {
slug: document.getElementById('shared-slug-input').value.trim(),
description: document.getElementById('shared-desc-input').value.trim(),
data
});
sharedCreateModal.hide();
await loadShared();
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
}
async function viewShared(s) {
document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')';
document.getElementById('shared-view-content').textContent = 'Loading...';
sharedViewModal.show();
try {
const res = await apiCall('GET', 'secret/data/' + s.path);
document.getElementById('shared-view-content').textContent =
(res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.';
} catch (e) {
document.getElementById('shared-view-content').textContent = 'Error: ' + e.message;
}
}
async function openGrants(id) {
currentShared = id;
document.getElementById('grants-error').classList.add('d-none');
document.getElementById('grant-id-input').value = '';
sharedGrantsModal.show();
try {
const res = await sharedApi('/' + id + '/grants');
const grants = (res && res.grants) || [];
const el = document.getElementById('grants-list');
el.innerHTML = '';
if (!grants.length) el.innerHTML = '<div class="list-group-item text-muted">No grants yet.</div>';
grants.forEach(g => {
const row = document.createElement('div');
row.className = 'list-group-item d-flex justify-content-between align-items-center';
row.innerHTML = `<span><span class="badge bg-secondary me-2">${g.granteeType}</span>${g.granteeId}</span>
<button class="btn btn-sm btn-outline-danger" onclick="revokeGrant('${g.id}')"><i class="fas fa-times"></i></button>`;
el.appendChild(row);
});
} catch (e) {
document.getElementById('grants-list').innerHTML = `<div class="list-group-item text-danger">${e.message}</div>`;
}
}
async function addGrant() {
const err = document.getElementById('grants-error');
err.classList.add('d-none');
try {
await sharedApi('/' + currentShared + '/grants', 'POST', {
granteeType: document.getElementById('grant-type-input').value,
granteeId: document.getElementById('grant-id-input').value.trim()
});
document.getElementById('grant-id-input').value = '';
openGrants(currentShared);
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
}
async function revokeGrant(grantId) {
try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); }
catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); }
}
async function deleteShared(id) {
const confirmed = await app.messages.confirm('Delete this shared secret? Grantees will immediately lose access.', $('#shared-mine-list'), 'warning');
if (!confirmed) return;
try { await sharedApi('/' + id, 'DELETE'); await loadShared(); }
catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); }
}
(async function init() {
const user = await app.auth.forceLogin();
if (!user) return; // not logged in — forceLogin redirected to /login
IS_ADMIN = app.auth.isAdmin();
VAULT_BASE = IS_ADMIN ? '' : 'users/' + user.uid + '/';
if (IS_ADMIN) {
document.getElementById('vault-apps-tab').style.display = '';
document.getElementById('vault-title').innerHTML =
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
}
loadSecrets();
loadShared();
})();
</script>
<%- include('bottom') %>