49100c9b68
- api_agent: REST router mounted synchronously in app.js (was post-listen, behind the 404 catch-all -> /api/agent/* 404'd); WS init stays on onListen - directory.ejs: promote opens a pre-filled resource modal (Save confirms); addEdge/removeEdge call loadResources() (was undefined loadData -> stale table); addGroup/removeGroup refresh the Access column - vault.ejs: 'Powered by OpenBao' header badge
632 lines
29 KiB
Plaintext
632 lines
29 KiB
Plaintext
<%- include('top') %>
|
|
|
|
<div class="container mt-4">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card shadow">
|
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
|
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li>
|
|
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li>
|
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li>
|
|
</ul>
|
|
<span class="small text-muted"><i class="fa-solid fa-database me-1"></i>Powered by <a href="https://openbao.org" target="_blank" rel="noopener">OpenBao</a></span>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="tab-content">
|
|
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
|
<div class="tab-pane fade show active" id="tab-secrets">
|
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<a href="/docs/vault" class="text-reset" title="Vault help & documentation"><i class="fa-solid fa-circle-question"></i></a>
|
|
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
|
</div>
|
|
</div>
|
|
<div class="p-3">
|
|
<div class="row">
|
|
<div class="col-md-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header"><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 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>
|
|
</div>
|
|
|
|
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
|
|
<div class="tab-pane fade" id="tab-apps">
|
|
<div class="p-3">
|
|
<div class="row">
|
|
<div class="col-md-5">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header"><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/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
|
|
<p class="text-muted small">The token is periodic: it stays valid as long as the app renews it within its period (<code>POST /v1/auth/token/renew-self</code>). If it lapses, mint a new one here — the app's policy and stored secrets are kept.</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 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/<name>/conf
|
|
curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf" \
|
|
-H "X-Vault-Token: <token above>"</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row mt-3">
|
|
<div class="col-12">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0"><i class="fa-solid fa-key me-1"></i> Minted apps</h5>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="loadApps()"><i class="fas fa-rotate"></i> Refresh</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="text-muted small mb-2">Each entry is a scoped OpenBao credential an external service uses to read <code>secret/apps/<name>/*</code>. The token itself is shown <strong>once</strong> at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.</p>
|
|
<div id="apps-list"><div class="text-muted small">Loading…</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
|
|
<div class="tab-pane fade" id="tab-shared">
|
|
<div class="p-3">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header 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"><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>
|
|
</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');
|
|
loadApps();
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
// List the minted external-app tokens (metadata only). Makes the Apps tab show
|
|
// what's been minted instead of a credential that vanishes after the once-only
|
|
// token display.
|
|
async function loadApps() {
|
|
const $list = document.getElementById('apps-list');
|
|
if (!$list) return;
|
|
$list.textContent = 'Loading…';
|
|
try {
|
|
const res = await fetch('/api/vault/apps', {
|
|
headers: { 'auth-token': app.auth.getToken() }
|
|
});
|
|
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
|
|
const { apps = [] } = await res.json();
|
|
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
|
|
$list.innerHTML = '<div class="list-group shadow-sm">' + apps.map(a => {
|
|
const ok = !a.lastError;
|
|
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
|
|
return `<div class="list-group-item d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
|
|
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
|
|
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
|
|
</div>
|
|
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
|
|
</div>`;
|
|
}).join('') + '</div>';
|
|
} catch (err) {
|
|
$list.innerHTML = '<div class="text-danger small">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
|
|
}
|
|
}
|
|
|
|
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';
|
|
loadApps();
|
|
}
|
|
loadSecrets();
|
|
loadShared();
|
|
})();
|
|
</script>
|
|
|
|
<%- include('bottom') %> |