Flip POST /api/v1/auth/admin/invites default to no email; always return the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/ Discord/paste-in-email. Email becomes an opt-in checkbox (was the default). Add shareText field with pre-formatted message for one-tap paste. Stop logging raw invite URLs to error.log when SMTP is unconfigured (was just a dev fallback — link is now in the response). Frontend flips the checkbox default to unchecked and renders shareText + native share sheet button (navigator.share) alongside the raw copy-link button. 9 new tests covering default-no-send, link-always-returned, shareText-shape, opt-in SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
522 lines
21 KiB
JavaScript
522 lines
21 KiB
JavaScript
/**
|
|
* Admin panel — DC-048.
|
|
*
|
|
* Minimal admin UI for managing users + invites. Rendered as a modal overlay
|
|
* triggered by an "Admin" button in the top bar that only appears when
|
|
* /api/v1/auth/me returns isAdmin=true. The panel renders three sections:
|
|
*
|
|
* 1. Users — list of authorized users with role badges, role-edit,
|
|
* delete actions.
|
|
* 2. Invite a user — form to issue a single-use invite (email, role,
|
|
* TTL). The accept-link is shown post-issue so the admin can copy it.
|
|
* 3. Outstanding invites — list of issued-not-yet-accepted invites
|
|
* with a revoke button.
|
|
*
|
|
* The panel does NOT add a tab to the dashboard nav — it lives as a modal
|
|
* to keep DC-048 surgical. Future DC-049 work can promote it to a tab.
|
|
*
|
|
* Behaviour:
|
|
* - On load: GET /me; if !isAdmin → show "admin only" placeholder
|
|
* - Then GET /admin/users + /admin/invites in parallel
|
|
* - Forms POST to the admin endpoints, refresh lists on success
|
|
* - "Copy link" button writes the acceptUrl to the clipboard
|
|
*
|
|
* Wires into the global error-handler (window.errorHandler) for failure
|
|
* surfaces. Uses window.SITE for any UI constants (none currently).
|
|
*/
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
const API = {
|
|
me: '/api/v1/auth/me',
|
|
users: '/api/v1/auth/admin/users',
|
|
allowlist: '/api/v1/auth/admin/allowlist',
|
|
invites: '/api/v1/auth/admin/invites',
|
|
};
|
|
|
|
function _el(tag, attrs, ...children) {
|
|
const node = document.createElement(tag);
|
|
if (attrs) {
|
|
for (const k of Object.keys(attrs)) {
|
|
const v = attrs[k];
|
|
if (v === null || v === undefined || v === false) continue;
|
|
if (k === 'class') node.className = v;
|
|
else if (k === 'text') node.textContent = v;
|
|
else if (k === 'html') node.innerHTML = v;
|
|
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
|
else node.setAttribute(k, v);
|
|
}
|
|
}
|
|
for (const c of children) {
|
|
if (c === null || c === undefined || c === false) continue;
|
|
if (typeof c === 'string') node.appendChild(document.createTextNode(c));
|
|
else node.appendChild(c);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
async function _fetchJSON(url, opts) {
|
|
const csrf = (window.SITE && window.SITE.csrfToken) || '';
|
|
opts = opts || {};
|
|
opts.headers = Object.assign(
|
|
{ 'Content-Type': 'application/json' },
|
|
opts.headers || {},
|
|
csrf ? { 'X-CSRF-Token': csrf } : {}
|
|
);
|
|
if (opts.body && typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body);
|
|
const r = await fetch(url, opts);
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
const msg = (data && (data.message || data.error)) || ('HTTP ' + r.status);
|
|
const err = new Error(msg);
|
|
err.status = r.status;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function _renderBadge(role) {
|
|
const colors = {
|
|
admin: 'background:#7c3aed;color:#fff',
|
|
operator: 'background:#2563eb;color:#fff',
|
|
viewer: 'background:#6b7280;color:#fff',
|
|
};
|
|
return _el('span', {
|
|
class: 'role-badge',
|
|
style: 'display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;' +
|
|
(colors[role] || colors.viewer),
|
|
text: role,
|
|
});
|
|
}
|
|
|
|
function _renderUsersList(container, users, onChange) {
|
|
container.innerHTML = '';
|
|
if (!users || users.length === 0) {
|
|
container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No users yet.' }));
|
|
return;
|
|
}
|
|
const table = _el('table', {
|
|
style: 'width:100%;border-collapse:collapse;font-size:0.9rem',
|
|
});
|
|
table.appendChild(_el('thead', null,
|
|
_el('tr', { style: 'border-bottom:1px solid var(--border)' },
|
|
_el('th', { style: 'text-align:left;padding:8px', text: 'Email' }),
|
|
_el('th', { style: 'text-align:left;padding:8px', text: 'Role' }),
|
|
_el('th', { style: 'text-align:left;padding:8px', text: 'Created' }),
|
|
_el('th', { style: 'text-align:left;padding:8px', text: 'Last login' }),
|
|
_el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }),
|
|
),
|
|
));
|
|
const tbody = _el('tbody');
|
|
for (const u of users) {
|
|
const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' });
|
|
|
|
const emailCell = _el('td', { style: 'padding:8px' });
|
|
emailCell.appendChild(_el('span', { text: u.email || '(no email)' }));
|
|
if (u.displayName && u.displayName !== (u.email || '').split('@')[0]) {
|
|
emailCell.appendChild(_el('br'));
|
|
emailCell.appendChild(_el('small', {
|
|
style: 'color:var(--muted)', text: u.displayName,
|
|
}));
|
|
}
|
|
row.appendChild(emailCell);
|
|
|
|
const roleCell = _el('td', { style: 'padding:8px' });
|
|
roleCell.appendChild(_renderBadge(u.role));
|
|
row.appendChild(roleCell);
|
|
|
|
row.appendChild(_el('td', {
|
|
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
|
|
text: u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—',
|
|
}));
|
|
row.appendChild(_el('td', {
|
|
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
|
|
text: u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '—',
|
|
}));
|
|
|
|
const actionsCell = _el('td', { style: 'padding:8px;text-align:right' });
|
|
const roleSelect = _el('select', {
|
|
style: 'padding:2px 6px;margin-right:6px',
|
|
onchange: async (ev) => {
|
|
try {
|
|
await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), {
|
|
method: 'PATCH', body: { role: ev.target.value },
|
|
});
|
|
onChange && onChange();
|
|
} catch (e) {
|
|
window.errorHandler && window.errorHandler.show('Role update failed: ' + e.message);
|
|
ev.target.value = u.role;
|
|
}
|
|
},
|
|
});
|
|
for (const r of ['admin', 'operator', 'viewer']) {
|
|
const opt = _el('option', { value: r, text: r });
|
|
if (r === u.role) opt.selected = true;
|
|
roleSelect.appendChild(opt);
|
|
}
|
|
actionsCell.appendChild(roleSelect);
|
|
|
|
const delBtn = _el('button', {
|
|
class: 'btn-sm', style: 'padding:2px 8px',
|
|
text: 'Delete',
|
|
onclick: async () => {
|
|
if (!confirm('Delete user ' + (u.email || u.id) + '? This cannot be undone.')) return;
|
|
try {
|
|
await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), { method: 'DELETE' });
|
|
onChange && onChange();
|
|
} catch (e) {
|
|
window.errorHandler && window.errorHandler.show('Delete failed: ' + e.message);
|
|
}
|
|
},
|
|
});
|
|
actionsCell.appendChild(delBtn);
|
|
row.appendChild(actionsCell);
|
|
|
|
tbody.appendChild(row);
|
|
}
|
|
table.appendChild(tbody);
|
|
container.appendChild(table);
|
|
}
|
|
|
|
function _renderInviteForm(container, onIssued) {
|
|
const form = _el('form', {
|
|
style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:end',
|
|
onsubmit: async (ev) => {
|
|
ev.preventDefault();
|
|
const fd = new FormData(ev.target);
|
|
const body = {
|
|
email: fd.get('email'),
|
|
role: fd.get('role'),
|
|
ttlHours: parseInt(fd.get('ttlHours'), 10) || 24,
|
|
sendEmail: fd.get('sendEmail') === 'on',
|
|
};
|
|
try {
|
|
const r = await _fetchJSON(API.invites, { method: 'POST', body });
|
|
ev.target.reset();
|
|
onIssued && onIssued(r);
|
|
} catch (e) {
|
|
window.errorHandler && window.errorHandler.show('Invite failed: ' + e.message);
|
|
}
|
|
},
|
|
});
|
|
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
|
|
_el('span', { text: 'Email' }),
|
|
_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }),
|
|
));
|
|
const roleSel = _el('select', { name: 'role', style: 'padding:6px' });
|
|
for (const r of ['operator', 'viewer', 'admin']) {
|
|
roleSel.appendChild(_el('option', { value: r, text: r }));
|
|
}
|
|
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
|
|
_el('span', { text: 'Role' }), roleSel,
|
|
));
|
|
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
|
|
_el('span', { text: 'TTL (hours)' }),
|
|
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
|
));
|
|
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
|
_el('input', { name: 'sendEmail', type: 'checkbox', checked: false }),
|
|
_el('span', { text: 'Also send via email (optional)' }),
|
|
));
|
|
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
|
container.appendChild(form);
|
|
}
|
|
|
|
function _renderInvitesList(container, invites, onChange) {
|
|
container.innerHTML = '';
|
|
if (!invites || invites.length === 0) {
|
|
container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No outstanding invites.' }));
|
|
return;
|
|
}
|
|
const table = _el('table', {
|
|
style: 'width:100%;border-collapse:collapse;font-size:0.9rem',
|
|
});
|
|
table.appendChild(_el('thead', null,
|
|
_el('tr', { style: 'border-bottom:1px solid var(--border)' },
|
|
_el('th', { style: 'text-align:left;padding:8px', text: 'Email' }),
|
|
_el('th', { style: 'text-align:left;padding:8px', text: 'Role' }),
|
|
_el('th', { style: 'text-align:left;padding:8px', text: 'Invited by' }),
|
|
_el('th', { style: 'text-align:left;padding:8px', text: 'Expires' }),
|
|
_el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }),
|
|
),
|
|
));
|
|
const tbody = _el('tbody');
|
|
for (const inv of invites) {
|
|
const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' });
|
|
row.appendChild(_el('td', { style: 'padding:8px', text: inv.email }));
|
|
row.appendChild(_el('td', { style: 'padding:8px' }, _renderBadge(inv.role)));
|
|
row.appendChild(_el('td', { style: 'padding:8px;color:var(--muted)', text: inv.invitedBy || '—' }));
|
|
row.appendChild(_el('td', {
|
|
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
|
|
text: inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : '—',
|
|
}));
|
|
const actionsCell = _el('td', { style: 'padding:8px;text-align:right' });
|
|
actionsCell.appendChild(_el('button', {
|
|
class: 'btn-sm', style: 'padding:2px 8px',
|
|
text: 'Revoke',
|
|
onclick: async () => {
|
|
if (!confirm('Revoke invite for ' + inv.email + '?')) return;
|
|
try {
|
|
await _fetchJSON(API.invites + '/' + encodeURIComponent(inv.id), { method: 'DELETE' });
|
|
onChange && onChange();
|
|
} catch (e) {
|
|
window.errorHandler && window.errorHandler.show('Revoke failed: ' + e.message);
|
|
}
|
|
},
|
|
}));
|
|
row.appendChild(actionsCell);
|
|
tbody.appendChild(row);
|
|
}
|
|
table.appendChild(tbody);
|
|
container.appendChild(table);
|
|
}
|
|
|
|
function _renderIssuedInviteBanner(invite, parent) {
|
|
const banner = _el('div', {
|
|
style: 'margin-top:12px;padding:12px;border:1px solid #16a34a;border-radius:6px;background:#052e1a;color:#bbf7d0;font-size:0.85rem',
|
|
});
|
|
banner.appendChild(_el('strong', { text: 'Invite issued — copy the link below. ' +
|
|
'It will not be shown again.' }));
|
|
banner.appendChild(_el('br'));
|
|
banner.appendChild(_el('code', {
|
|
style: 'display:block;margin-top:8px;padding:8px;background:#000;border-radius:4px;word-break:break-all;color:#d1fae5',
|
|
text: invite.acceptUrl,
|
|
}));
|
|
const copyBtn = _el('button', {
|
|
class: 'btn-sm', style: 'margin-top:8px;padding:4px 10px',
|
|
text: 'Copy link',
|
|
onclick: async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(invite.acceptUrl);
|
|
copyBtn.textContent = 'Copied!';
|
|
setTimeout(() => { copyBtn.textContent = 'Copy link'; }, 2000);
|
|
} catch (e) {
|
|
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the link manually.');
|
|
}
|
|
},
|
|
});
|
|
banner.appendChild(copyBtn);
|
|
|
|
// DC-085: pre-formatted message for one-tap paste into iMessage / WhatsApp /
|
|
// Telegram / SMS / Signal / Discord / paste-into-email. The operator can
|
|
// copy this as a sentence instead of dealing with the raw URL.
|
|
if (invite.shareText) {
|
|
const shareBlock = _el('div', { style: 'margin-top:12px' });
|
|
shareBlock.appendChild(_el('div', {
|
|
style: 'font-size:0.8rem;color:#86efac;margin-bottom:4px',
|
|
text: 'Share this message:',
|
|
}));
|
|
shareBlock.appendChild(_el('div', {
|
|
style: 'padding:8px;background:#000;border-radius:4px;color:#d1fae5;white-space:pre-wrap',
|
|
text: invite.shareText,
|
|
}));
|
|
const shareActions = _el('div', { style: 'margin-top:6px;display:flex;gap:6px;flex-wrap:wrap' });
|
|
const copyTextBtn = _el('button', {
|
|
class: 'btn-sm', style: 'padding:4px 10px',
|
|
text: 'Copy message',
|
|
onclick: async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(invite.shareText);
|
|
copyTextBtn.textContent = 'Copied!';
|
|
setTimeout(() => { copyTextBtn.textContent = 'Copy message'; }, 2000);
|
|
} catch (e) {
|
|
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the text manually.');
|
|
}
|
|
},
|
|
});
|
|
shareActions.appendChild(copyTextBtn);
|
|
// Native share sheet on mobile / supported browsers. Falls back silently
|
|
// (the copy buttons cover the same intent).
|
|
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
|
const nativeShareBtn = _el('button', {
|
|
class: 'btn-sm', style: 'padding:4px 10px',
|
|
text: 'Share via…',
|
|
onclick: async () => {
|
|
try {
|
|
await navigator.share({
|
|
title: 'DashCaddy invite',
|
|
text: invite.shareText,
|
|
url: invite.acceptUrl,
|
|
});
|
|
} catch (e) {
|
|
// User-cancelled throws AbortError — that's fine, just stay quiet.
|
|
if (e && e.name && e.name !== 'AbortError') {
|
|
window.errorHandler && window.errorHandler.show('Share failed: ' + e.message);
|
|
}
|
|
}
|
|
},
|
|
});
|
|
shareActions.appendChild(nativeShareBtn);
|
|
}
|
|
shareBlock.appendChild(shareActions);
|
|
banner.appendChild(shareBlock);
|
|
}
|
|
|
|
if (invite.deliveredVia === 'failed') {
|
|
banner.appendChild(_el('p', {
|
|
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
|
text: 'Email could not be sent (SMTP not configured). Share the link above instead — it works the same way.',
|
|
}));
|
|
} else if (invite.deliveredVia === 'email') {
|
|
banner.appendChild(_el('p', {
|
|
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
|
text: 'Email sent to ' + invite.email + '.',
|
|
}));
|
|
} else if (invite.deliveredVia === 'manual') {
|
|
banner.appendChild(_el('p', {
|
|
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
|
text: 'Share the link above via text, chat, or any messenger.',
|
|
}));
|
|
}
|
|
parent.appendChild(banner);
|
|
}
|
|
|
|
/**
|
|
* Mount the admin panel. Called by the open trigger; safe to call multiple
|
|
* times (re-renders into the same container).
|
|
*/
|
|
async function mount(container) {
|
|
container.innerHTML = '';
|
|
container.appendChild(_el('h2', { style: 'margin:0 0 16px', text: 'Admin · Users & Invites' }));
|
|
|
|
const meData = await _fetchJSON(API.me).catch(() => ({}));
|
|
if (!meData || !meData.user || meData.user.role !== 'admin') {
|
|
container.appendChild(_el('p', {
|
|
style: 'color:var(--muted)',
|
|
text: 'Admin role required to view this panel. If multi-user mode is enabled and you should have access, check /api/v1/auth/me.',
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Refresh button
|
|
const refreshBtn = _el('button', {
|
|
class: 'btn-sm', style: 'float:right;padding:4px 10px',
|
|
text: 'Refresh',
|
|
onclick: () => mount(container),
|
|
});
|
|
container.appendChild(refreshBtn);
|
|
|
|
// ── Users section ────────────────────────────────────────────────────
|
|
const usersHeader = _el('h3', { style: 'margin:24px 0 8px;clear:both', text: 'Users' });
|
|
container.appendChild(usersHeader);
|
|
|
|
const usersList = _el('div', { id: 'admin-users-list' });
|
|
container.appendChild(usersList);
|
|
|
|
const usersData = await _fetchJSON(API.users).catch(() => ({ users: [] }));
|
|
_renderUsersList(usersList, usersData.users, () => mount(container));
|
|
|
|
// Add user form (pre-authorize an email without issuing an invite).
|
|
container.appendChild(_el('h4', { style: 'margin:24px 0 8px;font-size:0.95rem', text: 'Pre-authorize email' }));
|
|
const addUserForm = _el('form', {
|
|
style: 'display:flex;gap:8px;align-items:end',
|
|
onsubmit: async (ev) => {
|
|
ev.preventDefault();
|
|
const email = ev.target.email.value.trim();
|
|
if (!email) return;
|
|
try {
|
|
await _fetchJSON(API.users, { method: 'POST', body: { email } });
|
|
ev.target.reset();
|
|
mount(container);
|
|
} catch (e) {
|
|
window.errorHandler && window.errorHandler.show('Add failed: ' + e.message);
|
|
}
|
|
},
|
|
});
|
|
addUserForm.appendChild(_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }));
|
|
addUserForm.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Add to allowlist' }));
|
|
container.appendChild(addUserForm);
|
|
|
|
// ── Invites section ──────────────────────────────────────────────────
|
|
container.appendChild(_el('h3', { style: 'margin:24px 0 8px', text: 'Issue invite' }));
|
|
const inviteFormContainer = _el('div');
|
|
container.appendChild(inviteFormContainer);
|
|
|
|
const invitesList = _el('div', { id: 'admin-invites-list', style: 'margin-top:16px' });
|
|
container.appendChild(invitesList);
|
|
|
|
const invitesData = await _fetchJSON(API.invites).catch(() => ({ invites: [] }));
|
|
_renderInvitesList(invitesList, invitesData.invites, () => mount(container));
|
|
|
|
_renderInviteForm(inviteFormContainer, (issued) => {
|
|
_renderIssuedInviteBanner(issued, inviteFormContainer);
|
|
mount(container);
|
|
});
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Open the admin panel as a modal overlay. Closes on backdrop click or
|
|
* the close button. Renders into document.body so it floats above the
|
|
* dashboard chrome.
|
|
*/
|
|
async function open() {
|
|
// If already open, just focus.
|
|
const existing = document.getElementById('admin-panel-root');
|
|
if (existing) return;
|
|
|
|
const backdrop = _el('div', {
|
|
id: 'admin-panel-root',
|
|
style: 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;',
|
|
onclick: (ev) => {
|
|
if (ev.target === backdrop) close();
|
|
},
|
|
});
|
|
|
|
const card = _el('div', {
|
|
style: 'background:var(--card-base,#1f2937);color:var(--text,#f3f4f6);border-radius:8px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;box-shadow:0 10px 30px rgba(0,0,0,0.3)',
|
|
});
|
|
card.appendChild(_el('button', {
|
|
class: 'btn-sm', style: 'position:absolute;top:12px;right:12px;padding:4px 10px',
|
|
text: 'Close', onclick: close,
|
|
}));
|
|
|
|
const body = _el('div', { id: 'admin-panel-body' });
|
|
card.appendChild(body);
|
|
backdrop.appendChild(card);
|
|
document.body.appendChild(backdrop);
|
|
|
|
try {
|
|
await mount(body);
|
|
} catch (e) {
|
|
body.innerHTML = '<p style="color:#f87171">Failed to load admin panel: ' + (e.message || e) + '</p>';
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
const existing = document.getElementById('admin-panel-root');
|
|
if (existing) existing.remove();
|
|
}
|
|
|
|
/**
|
|
* Inject an "Admin" button into the top bar. Only renders when the
|
|
* current /me response says isAdmin=true. Re-checks periodically
|
|
* (every 60s) so a permission downgrade takes effect without a reload.
|
|
*/
|
|
async function attachTrigger(barContainer) {
|
|
async function _maybeShow() {
|
|
const me = await _fetchJSON(API.me).catch(() => ({}));
|
|
const existing = document.getElementById('admin-trigger-btn');
|
|
if (me && me.user && me.user.role === 'admin') {
|
|
if (existing) return;
|
|
const btn = _el('button', {
|
|
id: 'admin-trigger-btn',
|
|
class: 'btn-sm',
|
|
style: 'margin-left:8px;padding:6px 12px',
|
|
text: 'Admin',
|
|
onclick: open,
|
|
});
|
|
if (barContainer) barContainer.appendChild(btn);
|
|
else if (document.body) document.body.appendChild(btn);
|
|
} else if (existing) {
|
|
existing.remove();
|
|
}
|
|
}
|
|
await _maybeShow();
|
|
setInterval(_maybeShow, 60_000);
|
|
}
|
|
|
|
window.AdminPanel = { open, close, attachTrigger };
|
|
})(); |