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:
parent
c3f8dc49ef
commit
5ec1ddec6b
10 changed files with 344 additions and 165 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue