Replace all browser dialogs with modern modal, add OIDC admin UI

- Add reusable showConfirm() modal component (supports plain confirm,
  input prompt, danger styling, Enter key)
- Replace ALL 18 confirm() and prompt() calls across 8 JS files with
  showConfirm() modal: admin user actions, session revoke, document
  delete, template delete, milestone management, transcription settings
- Fix broken admin reset-password (btn was undefined in scope)
- Add OIDC/SSO configuration UI to Admin Panel (issuer, client ID/secret,
  button label, disable local auth toggle, callback URL display)
This commit is contained in:
Daniel 2026-04-09 02:43:23 +02:00
parent c3f8dc49ef
commit 5ec1ddec6b
10 changed files with 344 additions and 165 deletions

View file

@ -91,6 +91,48 @@
</div>
</div>
<!-- ── OIDC / SSO Configuration ──────────────────────────────── -->
<div class="card">
<div class="card-header"><h3><i class="fas fa-shield-halved"></i> Single Sign-On (OIDC)</h3></div>
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
<p style="font-size:12px;color:var(--g500);margin:0;">Configure OpenID Connect for SSO with Azure AD, Okta, Keycloak, PocketID, Google, etc.<br>Callback URL: <code style="font-size:11px;background:var(--g100);padding:2px 6px;border-radius:4px;" id="oidc-callback-url"></code></p>
<div style="display:flex;align-items:center;gap:12px;">
<label style="font-size:13px;font-weight:600;min-width:160px;">Enable SSO:</label>
<select id="oidc-enabled" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label style="font-size:13px;font-weight:600;min-width:160px;">Issuer URL:</label>
<input type="url" id="oidc-issuer" placeholder="https://id.example.com" style="flex:1;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;max-width:400px;">
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label style="font-size:13px;font-weight:600;min-width:160px;">Client ID:</label>
<input type="text" id="oidc-client-id" placeholder="your-client-id" style="flex:1;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;max-width:400px;">
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label style="font-size:13px;font-weight:600;min-width:160px;">Client Secret:</label>
<input type="password" id="oidc-client-secret" placeholder="Leave blank to keep current" style="flex:1;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;max-width:400px;">
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label style="font-size:13px;font-weight:600;min-width:160px;">Button Label:</label>
<input type="text" id="oidc-button-label" placeholder="Sign in with SSO" style="flex:1;font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;max-width:400px;">
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label style="font-size:13px;font-weight:600;min-width:160px;">Disable local login:</label>
<select id="oidc-disable-local" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
<option value="false">No (both SSO and local login)</option>
<option value="true">Yes (SSO only)</option>
</select>
</div>
<div>
<button id="btn-save-oidc" class="btn-sm btn-primary"><i class="fas fa-floppy-disk"></i> Save OIDC Settings</button>
<span id="oidc-save-status" style="font-size:12px;margin-left:8px;color:var(--g500);"></span>
</div>
</div>
</div>
<!-- ── CMS: Email Templates ───────────────────────────────────── -->
<div class="card">
<div class="card-header"><h3><i class="fas fa-envelope"></i> Email Templates</h3></div>

View file

@ -237,6 +237,11 @@ textarea.full-input{resize:vertical;}
.spinner{width:32px;height:32px;border:4px solid var(--g200);border-top-color:var(--blue);border-radius:50%;animation:spin 0.7s linear infinite;margin:0 auto 12px;}
@keyframes spin{to{transform:rotate(360deg);}}
.loading-box p{color:var(--g600);font-weight:500;font-size:13px;}
.confirm-modal{position:fixed;inset:0;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;z-index:10000;backdrop-filter:blur(2px);}
.confirm-modal.hidden{display:none;}
.confirm-modal-box{background:white;padding:24px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.15);max-width:420px;width:90%;animation:modalIn 0.15s ease;}
.confirm-modal-box p{margin:0;font-size:14px;line-height:1.6;color:var(--g800);}
@keyframes modalIn{from{opacity:0;transform:scale(0.95)} to{opacity:1;transform:scale(1)}}
/* FAQ */
.faq-container{max-width:800px;margin:0 auto;}

View file

@ -280,6 +280,20 @@
</div>
<!-- Confirmation Modal -->
<div id="confirm-modal" class="confirm-modal hidden">
<div class="confirm-modal-box">
<p id="confirm-modal-text"></p>
<div id="confirm-modal-input-wrap" style="display:none;margin:10px 0;">
<input type="text" id="confirm-modal-input" style="width:100%;padding:8px;border:1px solid var(--g300);border-radius:6px;font-size:13px;">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:14px;">
<button id="confirm-modal-cancel" class="btn-sm btn-ghost">Cancel</button>
<button id="confirm-modal-ok" class="btn-sm btn-primary">Confirm</button>
</div>
</div>
</div>
<!-- LOADING -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loading-box"><div class="spinner"></div><p id="loading-text">Processing...</p></div>

View file

@ -27,6 +27,7 @@
function loadAdmin() {
loadSettings();
loadUsers();
loadOidcConfig();
}
// ---- SETTINGS + STATS ----
@ -162,17 +163,19 @@
switch (action) {
case 'verify':
if (!confirm('Verify email for ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/verify', {}, function() {
showToast(email + ' verified', 'success'); loadUsers();
showConfirm('Verify email for ' + email + '?', function() {
adminPost('/api/admin/users/' + userId + '/verify', {}, function() {
showToast(email + ' verified', 'success'); loadUsers();
});
});
break;
case 'disable':
if (!confirm('Disable account for ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/disable', {}, function() {
showToast(email + ' disabled', 'info'); loadUsers();
});
showConfirm('Disable account for ' + email + '?', function() {
adminPost('/api/admin/users/' + userId + '/disable', {}, function() {
showToast(email + ' disabled', 'info'); loadUsers();
});
}, { danger: true, confirmText: 'Disable' });
break;
case 'enable':
@ -182,66 +185,56 @@
break;
case 'promote':
if (!confirm('Grant admin role to ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'admin' }, function() {
showToast(email + ' is now admin', 'success'); loadUsers();
showConfirm('Grant admin role to ' + email + '?', function() {
adminPost('/api/admin/users/' + userId + '/role', { role: 'admin' }, function() {
showToast(email + ' is now admin', 'success'); loadUsers();
});
});
break;
case 'demote':
if (!confirm('Remove admin role from ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' demoted to user', 'info'); loadUsers();
showConfirm('Remove admin role from ' + email + '?', function() {
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' demoted to user', 'info'); loadUsers();
});
});
break;
case 'set-moderator':
if (!confirm('Set ' + email + ' as moderator? They will be able to manage Learning Hub content.')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'moderator' }, function() {
showToast(email + ' is now a moderator', 'success'); loadUsers();
showConfirm('Set ' + email + ' as moderator? They will be able to manage Learning Hub content.', function() {
adminPost('/api/admin/users/' + userId + '/role', { role: 'moderator' }, function() {
showToast(email + ' is now a moderator', 'success'); loadUsers();
});
});
break;
case 'set-user':
if (!confirm('Set ' + email + ' to full user role?')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' is now a user', 'success'); loadUsers();
showConfirm('Set ' + email + ' to regular user role?', function() {
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' is now a user', 'success'); loadUsers();
});
});
break;
case 'reset-pw':
// Show inline password input instead of prompt()
var existingResetRow = document.getElementById('admin-reset-pw-row');
if (existingResetRow) existingResetRow.remove();
var actionCell = btn.closest('td') || btn.parentElement;
var resetRow = document.createElement('div');
resetRow.id = 'admin-reset-pw-row';
resetRow.style.cssText = 'margin-top:8px;display:flex;gap:6px;align-items:center;';
resetRow.innerHTML = '<input type="password" id="admin-reset-pw-input" placeholder="New password (8+)" style="padding:5px 8px;border:1px solid var(--g300);border-radius:6px;font-size:12px;width:160px;">'
+ '<button class="btn-sm btn-primary" id="admin-reset-pw-submit" style="font-size:11px;">Set</button>'
+ '<button class="btn-sm btn-ghost" id="admin-reset-pw-cancel" style="font-size:11px;">Cancel</button>';
actionCell.appendChild(resetRow);
document.getElementById('admin-reset-pw-input').focus();
document.getElementById('admin-reset-pw-submit').onclick = function() {
var val = document.getElementById('admin-reset-pw-input').value;
if (!val || val.length < 8) { showToast('Password must be 8+ characters', 'error'); return; }
showConfirm('New password for ' + email + ' (8+ characters):', function(val) {
if (val.length < 8) { showToast('Password must be 8+ characters', 'error'); return; }
adminPost('/api/admin/users/' + userId + '/reset-password', { newPassword: val }, function() {
showToast('Password reset for ' + email, 'success');
resetRow.remove();
});
};
document.getElementById('admin-reset-pw-cancel').onclick = function() { resetRow.remove(); };
}, { input: true, inputType: 'password', placeholder: 'New password', required: true, requiredMsg: 'Enter a password', confirmText: 'Reset Password' });
break;
case 'delete':
if (!confirm('Permanently delete user ' + (name || email) + '?\n\nThis cannot be undone.')) return;
fetch('/api/admin/users/' + userId, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast(email + ' deleted', 'info'); loadUsers(); }
else showToast(data.error || 'Delete failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
showConfirm('Permanently delete user ' + (name || email) + '? This cannot be undone.', function() {
fetch('/api/admin/users/' + userId, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast(email + ' deleted', 'info'); loadUsers(); }
else showToast(data.error || 'Delete failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}, { danger: true, confirmText: 'Delete' });
break;
}
}
@ -435,6 +428,66 @@
// ---- AI PROMPTS ----
// ---- OIDC / SSO CONFIG ----
function loadOidcConfig() {
// Set callback URL display
var callbackEl = document.getElementById('oidc-callback-url');
if (callbackEl) callbackEl.textContent = window.location.origin + '/api/auth/oidc/callback';
fetch('/api/auth/oidc/config', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var c = data.config || {};
var el;
el = document.getElementById('oidc-enabled'); if (el) el.value = c['oidc.enabled'] || 'false';
el = document.getElementById('oidc-issuer'); if (el) el.value = c['oidc.issuer'] || '';
el = document.getElementById('oidc-client-id'); if (el) el.value = c['oidc.client_id'] || '';
el = document.getElementById('oidc-client-secret'); if (el) el.placeholder = c['oidc.client_secret'] ? c['oidc.client_secret'] : 'Enter client secret';
el = document.getElementById('oidc-button-label'); if (el) el.value = c['oidc.button_label'] || '';
el = document.getElementById('oidc-disable-local'); if (el) el.value = c['oidc.disable_local_auth'] || 'false';
})
.catch(function() {});
}
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-save-oidc' || e.target.closest('#btn-save-oidc')) {
e.preventDefault();
var status = document.getElementById('oidc-save-status');
if (status) { status.textContent = 'Saving...'; status.style.color = 'var(--g500)'; }
var body = {
'oidc.enabled': document.getElementById('oidc-enabled').value,
'oidc.issuer': document.getElementById('oidc-issuer').value.trim(),
'oidc.client_id': document.getElementById('oidc-client-id').value.trim(),
'oidc.button_label': document.getElementById('oidc-button-label').value.trim(),
'oidc.disable_local_auth': document.getElementById('oidc-disable-local').value
};
// Only send secret if user typed a new one
var secretEl = document.getElementById('oidc-client-secret');
if (secretEl && secretEl.value) body['oidc.client_secret'] = secretEl.value;
fetch('/api/auth/oidc/config', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('OIDC settings saved', 'success');
if (status) status.textContent = '';
if (secretEl) secretEl.value = '';
loadOidcConfig();
} else {
showToast(data.error || 'Save failed', 'error');
if (status) status.textContent = '';
}
})
.catch(function() { showToast('Save failed', 'error'); if (status) status.textContent = ''; });
}
});
function loadPromptList() {
fetch('/api/admin/config/prompts', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
@ -485,25 +538,25 @@
var sel = document.getElementById('cms-prompt-select');
if (!sel || !sel.value) return;
var key = sel.value;
if (!confirm('Reset "' + key + '" to the hardcoded default? This cannot be undone.')) return;
fetch('/api/admin/config/prompts/' + key + '/reset', {
method: 'POST',
headers: getAuthHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
if (!window._adminPrompts) window._adminPrompts = {};
window._adminPrompts[key] = data.value;
var textarea = document.getElementById('cms-prompt-text');
if (textarea) textarea.value = data.value;
showToast('Prompt reset to default', 'success');
} else {
showToast(data.error || 'Reset failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
showConfirm('Reset "' + key + '" to the hardcoded default? This cannot be undone.', function() {
fetch('/api/admin/config/prompts/' + key + '/reset', {
method: 'POST',
headers: getAuthHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
if (!window._adminPrompts) window._adminPrompts = {};
window._adminPrompts[key] = data.value;
var textarea = document.getElementById('cms-prompt-text');
if (textarea) textarea.value = data.value;
showToast('Prompt reset to default', 'success');
} else {
showToast(data.error || 'Reset failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
});
}
// ---- SMTP ----
@ -550,14 +603,15 @@
}
function clearSmtp() {
if (!confirm('Clear DB SMTP settings? Env vars will be used if set.')) return;
fetch('/api/admin/config/smtp', { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('SMTP DB settings cleared', 'info'); loadSmtp(); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
showConfirm('Clear DB SMTP settings? Env vars will be used if set.', function() {
fetch('/api/admin/config/smtp', { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('SMTP DB settings cleared', 'info'); loadSmtp(); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
});
}
// ---- AUTO-DELETE / RESET ----
@ -574,17 +628,18 @@
}
function resetAllDefaults() {
if (!confirm('Reset all settings to factory defaults?\n\nAnnouncements, feature flags, email templates will be reset. SMTP and custom models are preserved.')) return;
fetch('/api/admin/config/reset-defaults', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast(data.message || 'Reset to defaults', 'success');
cmsLoaded = false;
loadCms();
} else showToast(data.error || 'Reset failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
showConfirm('Reset all settings to factory defaults? Announcements, feature flags, email templates will be reset. SMTP and custom models are preserved.', function() {
fetch('/api/admin/config/reset-defaults', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast(data.message || 'Reset to defaults', 'success');
cmsLoaded = false;
loadCms();
} else showToast(data.error || 'Reset failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}, { danger: true, confirmText: 'Reset' });
}
// ---- HELPERS ----
@ -887,18 +942,19 @@
}
function clearAllModels() {
if (!confirm('Remove all added models? You will need to re-add them via Search API.')) return;
Promise.all([
fetch('/api/admin/config/models/clear-all', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
]).then(function(results) {
if (results[0].success) {
showToast('All models cleared', 'info');
loadAdminModels();
} else {
showToast(results[0].error || 'Failed', 'error');
}
}).catch(function() { showToast('Request failed', 'error'); });
showConfirm('Remove all added models? You will need to re-add them via Search API.', function() {
Promise.all([
fetch('/api/admin/config/models/clear-all', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
]).then(function(results) {
if (results[0].success) {
showToast('All models cleared', 'info');
loadAdminModels();
} else {
showToast(results[0].error || 'Failed', 'error');
}
}).catch(function() { showToast('Request failed', 'error'); });
});
}
function saveDefaultModel() {

View file

@ -30,9 +30,9 @@
document.getElementById('btn-save-milestone')?.addEventListener('click', saveMilestone);
document.getElementById('btn-bulk-import-milestones')?.addEventListener('click', bulkImportMilestones);
document.getElementById('btn-reimport-milestones')?.addEventListener('click', function() {
if (confirm('This will DELETE all existing milestones and re-import from static data. Continue?')) {
showConfirm('This will DELETE all existing milestones and re-import from static data. Continue?', function() {
bulkImportMilestones(true);
}
}, { danger: true, confirmText: 'Re-import' });
});
// Filter
@ -239,26 +239,26 @@
};
window.deleteMilestone = function(id) {
if (!confirm('Delete this milestone? This action cannot be undone.')) return;
fetch('/api/admin/milestones/' + id, {
method: 'DELETE',
headers: getAuthHeaders()
})
.then(r => r.json())
.then(data => {
if (!data.success) {
showToast(data.error || 'Failed to delete', 'error');
return;
}
showToast('Milestone deleted', 'success');
loadMetadata();
loadMilestones();
})
.catch(err => {
console.error('[AdminMilestones] Delete error:', err);
showToast('Failed to delete: ' + err.message, 'error');
});
showConfirm('Delete this milestone? This action cannot be undone.', function() {
fetch('/api/admin/milestones/' + id, {
method: 'DELETE',
headers: getAuthHeaders()
})
.then(r => r.json())
.then(data => {
if (!data.success) {
showToast(data.error || 'Failed to delete', 'error');
return;
}
showToast('Milestone deleted', 'success');
loadMetadata();
loadMilestones();
})
.catch(err => {
console.error('[AdminMilestones] Delete error:', err);
showToast('Failed to delete: ' + err.message, 'error');
});
}, { danger: true, confirmText: 'Delete' });
};
function bulkImportMilestones(clearExisting) {

View file

@ -440,6 +440,61 @@ function hideBusy() {
if (bar) bar.classList.remove('active');
}
// Reusable confirmation modal — replaces browser confirm() and prompt()
// Usage: showConfirm('Are you sure?', function() { doSomething(); });
// With input: showConfirm('Enter email:', function(value) { send(value); }, { input: true, placeholder: 'email@example.com', inputType: 'email' });
// With danger styling: showConfirm('Delete user?', function() { deleteUser(); }, { danger: true, confirmText: 'Delete' });
window.showConfirm = function(message, onConfirm, opts) {
opts = opts || {};
var modal = document.getElementById('confirm-modal');
var text = document.getElementById('confirm-modal-text');
var okBtn = document.getElementById('confirm-modal-ok');
var cancelBtn = document.getElementById('confirm-modal-cancel');
var inputWrap = document.getElementById('confirm-modal-input-wrap');
var inputEl = document.getElementById('confirm-modal-input');
if (!modal || !text || !okBtn) return;
text.textContent = message;
okBtn.textContent = opts.confirmText || 'Confirm';
if (opts.danger) { okBtn.style.background = 'var(--red)'; okBtn.style.borderColor = 'var(--red)'; }
else { okBtn.style.background = ''; okBtn.style.borderColor = ''; }
if (opts.input) {
inputWrap.style.display = 'block';
inputEl.type = opts.inputType || 'text';
inputEl.placeholder = opts.placeholder || '';
inputEl.value = opts.defaultValue || '';
setTimeout(function() { inputEl.focus(); }, 50);
} else {
inputWrap.style.display = 'none';
}
modal.classList.remove('hidden');
function cleanup() {
modal.classList.add('hidden');
okBtn.onclick = null;
cancelBtn.onclick = null;
inputEl.value = '';
}
cancelBtn.onclick = function() { cleanup(); };
okBtn.onclick = function() {
if (opts.input) {
var val = inputEl.value;
if (opts.required && !val.trim()) { showToast(opts.requiredMsg || 'This field is required', 'error'); return; }
cleanup();
if (onConfirm) onConfirm(val);
} else {
cleanup();
if (onConfirm) onConfirm();
}
};
// Allow Enter key to confirm
inputEl.onkeydown = function(e) { if (e.key === 'Enter') okBtn.click(); };
};
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container) { console.log('Toast:', type, message); return; }

View file

@ -560,13 +560,14 @@ document.addEventListener('DOMContentLoaded', function() {
// Wire revoke buttons
list.querySelectorAll('.session-revoke-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!confirm('Revoke this session? That device will be logged out.')) return;
fetch('/api/sessions/' + btn.dataset.sid, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { showToast('Session revoked', 'info'); loadSessions(); }
else showToast(d.error || 'Failed', 'error');
});
showConfirm('Revoke this session? That device will be logged out.', function() {
fetch('/api/sessions/' + btn.dataset.sid, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { showToast('Session revoked', 'info'); loadSessions(); }
else showToast(d.error || 'Failed', 'error');
});
});
});
});
})
@ -579,12 +580,13 @@ document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-revoke-all-sessions' || e.target.closest('#btn-revoke-all-sessions')) {
e.preventDefault();
if (!confirm('Revoke all other sessions? All other devices will be logged out.')) return;
fetch('/api/sessions', { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { showToast('All other sessions revoked (' + (d.revoked || 0) + ' removed)', 'info'); loadSessions(); }
});
showConfirm('Revoke all other sessions? All other devices will be logged out.', function() {
fetch('/api/sessions', { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { showToast('All other sessions revoked (' + (d.revoked || 0) + ' removed)', 'info'); loadSessions(); }
});
}, { danger: true, confirmText: 'Revoke All' });
}
});

View file

@ -96,14 +96,15 @@
}
function deleteDocument(id) {
if (!confirm('Delete this document permanently?')) return;
fetch('/api/documents/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('Document deleted', 'info'); loadDocuments(); }
else showToast(data.error || 'Delete failed', 'error');
})
.catch(function() { showToast('Delete failed', 'error'); });
showConfirm('Delete this document permanently?', function() {
fetch('/api/documents/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('Document deleted', 'info'); loadDocuments(); }
else showToast(data.error || 'Delete failed', 'error');
})
.catch(function() { showToast('Delete failed', 'error'); });
}, { danger: true, confirmText: 'Delete' });
}
function esc(str) {

View file

@ -214,14 +214,15 @@
function deleteMemory(id) {
var mem = _memories.find(function(m) { return m.id === id; });
if (!confirm('Delete template "' + (mem ? mem.name : '') + '"?')) return;
fetch('/api/memories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('Template deleted', 'info'); loadMemories(); }
else showToast(data.error || 'Delete failed', 'error');
})
.catch(function() { showToast('Delete failed', 'error'); });
showConfirm('Delete template "' + (mem ? mem.name : '') + '"?', function() {
fetch('/api/memories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('Template deleted', 'info'); loadMemories(); }
else showToast(data.error || 'Delete failed', 'error');
})
.catch(function() { showToast('Delete failed', 'error'); });
}, { danger: true, confirmText: 'Delete' });
}
// ── Event wiring ─────────────────────────────────────────────────────────

View file

@ -126,28 +126,31 @@
speechCheckbox.addEventListener('change', function() {
if (speechCheckbox.checked) {
// Show confirmation for privacy warning
if (!confirm(
showConfirm(
'WARNING: Real-time streaming uses your browser\'s speech recognition, ' +
'which may send audio to cloud servers (e.g., Google).\n\n' +
'This is NOT HIPAA-compliant.\n\n' +
'Only enable if you understand and accept this privacy trade-off.\n\n' +
'Continue?'
)) {
speechCheckbox.checked = false;
return;
}
// Disable Browser Whisper if enabling Web Speech
if (whisperCheckbox && whisperCheckbox.checked) {
whisperCheckbox.checked = false;
BrowserWhisper.setEnabled(false);
whisperStatus.textContent = 'Off';
showToast('Browser Whisper disabled (Web Speech takes priority)', 'info');
}
'which may send audio to cloud servers (e.g., Google). ' +
'This is NOT HIPAA-compliant. ' +
'Only enable if you understand and accept this privacy trade-off.',
function() {
// Disable Browser Whisper if enabling Web Speech
if (whisperCheckbox && whisperCheckbox.checked) {
whisperCheckbox.checked = false;
BrowserWhisper.setEnabled(false);
whisperStatus.textContent = 'Off';
showToast('Browser Whisper disabled (Web Speech takes priority)', 'info');
}
WebSpeechRecognition.setEnabled(true);
speechStatus.textContent = 'On — real-time streaming';
},
{ danger: true, confirmText: 'Enable' }
);
// Revert checkbox until confirmed
speechCheckbox.checked = false;
return;
}
WebSpeechRecognition.setEnabled(speechCheckbox.checked);
speechStatus.textContent = speechCheckbox.checked ? 'On — real-time streaming' : 'Off';
WebSpeechRecognition.setEnabled(false);
speechStatus.textContent = 'Off';
});
// If not supported, disable and show message