Feature user and admin panels (#30)

This commit is contained in:
2026-02-16 20:30:36 +00:00
parent c5866ae785
commit 2fc4f6980c
15 changed files with 1805 additions and 111 deletions

View File

@@ -850,3 +850,352 @@ body {
min-height: 1.2rem;
margin-top: 0.5rem;
}
/* ── App Hidden (splash gate active) ─────── */
.app-hidden {
display: none !important;
}
#appContainer {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ── Splash Gate ─────────────────────────── */
.splash-gate {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
background: var(--bg-primary);
align-items: center;
justify-content: center;
}
.splash-gate.visible {
display: flex;
}
.splash-card {
width: 100%;
max-width: 400px;
padding: 2rem;
}
.splash-brand {
text-align: center;
margin-bottom: 2rem;
}
.splash-logo {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.splash-brand h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.splash-tagline {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.splash-form {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.splash-form .auth-tabs {
margin-bottom: 1rem;
}
.splash-actions {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.btn-full {
width: 100%;
justify-content: center;
}
.splash-divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.25rem 0;
color: var(--text-secondary);
font-size: 0.8rem;
}
.splash-divider::before,
.splash-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* ── Admin Panel ─────────────────────────── */
.admin-modal {
max-width: 700px;
width: 90vw;
}
.admin-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 1rem;
}
.admin-tab {
padding: 0.5rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.9rem;
}
.admin-tab:hover { color: var(--text-primary); }
.admin-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.admin-user-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border);
}
.admin-user-row:last-child { border-bottom: none; }
.admin-user-info {
flex: 1;
min-width: 0;
}
.admin-user-name {
font-weight: 600;
display: block;
}
.admin-user-email {
font-size: 0.8rem;
color: var(--text-secondary);
}
.admin-user-meta {
display: flex;
gap: 0.4rem;
}
.admin-badge {
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.admin-badge-admin { background: #7c3aed22; color: #a78bfa; }
.admin-badge-user { background: #3b82f622; color: #60a5fa; }
.admin-badge-moderator { background: #f59e0b22; color: #fbbf24; }
.admin-badge-active { background: #10b98122; color: #34d399; }
.admin-badge-inactive { background: #ef444422; color: #f87171; }
.admin-user-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.admin-role-select {
padding: 0.2rem 0.4rem;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.8rem;
}
.admin-you {
color: var(--text-secondary);
font-size: 0.8rem;
font-style: italic;
}
.btn-warning { background: #f59e0b; color: #000; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-success { background: #10b981; color: #fff; }
.btn-small { padding: 0.2rem 0.5rem; font-size: 0.75rem; }
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.admin-stat-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.admin-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
}
.admin-stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.admin-loading, .admin-empty, .admin-error {
padding: 1rem;
text-align: center;
color: var(--text-secondary);
}
.admin-error { color: #ef4444; }
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* ── Admin Add User / Reset Password ─────── */
.admin-add-user-toggle {
margin-bottom: 0.75rem;
}
.admin-add-user-form {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.admin-form-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.admin-form-row:last-child {
margin-bottom: 0;
}
.admin-input {
flex: 1;
padding: 0.4rem 0.6rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.85rem;
}
.admin-input:focus {
outline: none;
border-color: var(--accent);
}
.admin-reset-dialog {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 8px;
}
.admin-reset-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
min-width: 300px;
}
.admin-reset-card h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.admin-reset-card p {
color: var(--text-secondary);
font-size: 0.85rem;
margin: 0 0 0.75rem 0;
}
.admin-reset-card .admin-input {
width: 100%;
margin-bottom: 0.75rem;
box-sizing: border-box;
}
.admin-modal .modal-body {
position: relative;
}
/* ── Profile Section in Settings ─────────── */
.settings-section-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--accent);
margin: 0 0 0.75rem 0;
}
.settings-divider {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
.profile-password-row {
margin-top: 0.5rem;
}
#profileChangePwForm {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
}
#profileChangePwForm .form-group {
margin-bottom: 0.5rem;
}

View File

@@ -4,9 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Switchboard</title>
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/styles.css?v=0.3.4">
</head>
<body>
<div id="appContainer" class="app-hidden" style="display:none">
<header class="header">
<h1>🔀 Chat Switchboard</h1>
<div class="header-actions">
@@ -29,6 +30,9 @@
<button class="dropdown-item" onclick="exportChat('text')">📝 Plain Text</button>
</div>
</div>
<button class="btn btn-secondary admin-only" id="adminBtn" title="Admin Panel" style="display:none">
🛡️ Admin
</button>
<button class="btn btn-secondary" id="settingsBtn" title="Settings (Ctrl+,)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
@@ -113,6 +117,36 @@
<button class="modal-close" id="closeModalBtn"></button>
</div>
<div class="modal-body">
<!-- Profile (managed mode only) -->
<div class="settings-section" id="profileSection" style="display:none">
<h3 class="settings-section-title">Profile</h3>
<div class="form-group">
<label for="profileDisplayName">Display Name</label>
<input type="text" id="profileDisplayName" placeholder="How you want to be called">
</div>
<div class="form-group">
<label for="profileEmail">Email</label>
<input type="email" id="profileEmail" placeholder="you@example.com">
</div>
<div class="profile-password-row">
<button class="btn btn-secondary btn-small" id="profileChangePwBtn">🔑 Change Password</button>
</div>
<div id="profileChangePwForm" style="display:none">
<div class="form-group">
<label for="profileCurrentPw">Current Password</label>
<input type="password" id="profileCurrentPw" placeholder="••••••••">
</div>
<div class="form-group">
<label for="profileNewPw">New Password</label>
<input type="password" id="profileNewPw" placeholder="At least 8 characters">
</div>
<button class="btn btn-primary btn-small" id="profileSavePwBtn">Update Password</button>
</div>
<hr class="settings-divider">
</div>
<!-- API Settings (unmanaged / offline only) -->
<div id="unmanagedSettings">
<div class="form-group">
<label for="apiEndpoint">API Endpoint</label>
<input type="text" id="apiEndpoint" placeholder="https://api.openai.com/v1">
@@ -124,6 +158,7 @@
<input type="password" id="apiKey" placeholder="sk-...">
<small>Your API key for authentication</small>
</div>
</div> <!-- /unmanagedSettings -->
<div class="form-group">
<label for="model">Default Model</label>
@@ -198,14 +233,97 @@
</div>
</div>
<!-- Auth Modal -->
<div class="modal-overlay" id="authModal">
<div class="modal auth-modal">
<!-- Admin Panel Modal -->
<div class="modal-overlay" id="adminModal">
<div class="modal admin-modal">
<div class="modal-header">
<h2>🔀 Chat Switchboard</h2>
<button class="modal-close" id="authCloseBtn"></button>
<h2>🛡️ Admin Panel</h2>
<button class="modal-close" id="adminCloseBtn"></button>
</div>
<div class="modal-body">
<div class="admin-tabs">
<button class="admin-tab active" data-tab="users">Users</button>
<button class="admin-tab" data-tab="settings">Settings</button>
<button class="admin-tab" data-tab="stats">Stats</button>
</div>
<!-- Users Tab -->
<div class="admin-tab-content active" id="adminUsersTab">
<div class="admin-add-user-toggle">
<button class="btn btn-primary btn-small" id="adminShowAddUser">+ Add User</button>
</div>
<div class="admin-add-user-form" id="adminAddUserForm" style="display:none">
<div class="admin-form-row">
<input type="text" id="adminNewUsername" placeholder="Username" class="admin-input">
<input type="email" id="adminNewEmail" placeholder="Email" class="admin-input">
</div>
<div class="admin-form-row">
<input type="password" id="adminNewPassword" placeholder="Password (min 8)" class="admin-input">
<select id="adminNewRole" class="admin-role-select">
<option value="user">user</option>
<option value="admin">admin</option>
<option value="moderator">moderator</option>
</select>
</div>
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="adminCreateUserBtn">Create</button>
<button class="btn btn-secondary btn-small" id="adminCancelAddUser">Cancel</button>
</div>
</div>
<div id="adminUserList" class="admin-user-list">Loading...</div>
</div>
<!-- Reset Password Dialog (inline) -->
<div class="admin-reset-dialog" id="adminResetDialog" style="display:none">
<div class="admin-reset-card">
<h3>Reset Password</h3>
<p id="adminResetTarget"></p>
<input type="password" id="adminResetNewPw" placeholder="New password (min 8)" class="admin-input">
<div class="admin-form-row">
<button class="btn btn-primary btn-small" id="adminResetConfirmBtn">Reset</button>
<button class="btn btn-secondary btn-small" id="adminResetCancelBtn">Cancel</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="adminRegToggle" checked>
<span>Allow new user registration</span>
</label>
</div>
<div class="form-group">
<label for="adminSiteName">Site Name</label>
<input type="text" id="adminSiteName" value="Chat Switchboard">
</div>
<div class="form-group">
<label for="adminTagline">Tagline</label>
<input type="text" id="adminTagline" value="Multi-Model AI Chat">
</div>
<button class="btn btn-primary" id="adminSaveSettings">💾 Save</button>
</div>
<!-- Stats Tab -->
<div class="admin-tab-content" id="adminStatsTab" style="display:none">
<div id="adminStats" class="admin-stats">Loading...</div>
</div>
</div>
</div>
</div>
</div> <!-- /appContainer -->
<!-- ── Auth Splash Gate ────────────────── -->
<div class="splash-gate" id="splashGate" style="display:none">
<div class="splash-card">
<div class="splash-brand">
<div class="splash-logo">🔀</div>
<h1>Chat Switchboard</h1>
<p class="splash-tagline">Multi-Model AI Chat</p>
</div>
<div class="splash-form">
<div class="auth-tabs">
<button class="auth-tab active" id="authTabLogin">Sign In</button>
<button class="auth-tab" id="authTabRegister">Register</button>
@@ -235,22 +353,24 @@
</div>
</div>
<div class="auth-error" id="authError"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="authSkipBtn">Use Offline</button>
<button class="btn btn-primary" id="authLoginBtn">Sign In</button>
<button class="btn btn-primary" id="authRegisterBtn" style="display:none;">Create Account</button>
<div class="splash-actions">
<button class="btn btn-primary btn-full" id="authLoginBtn">Sign In</button>
<button class="btn btn-primary btn-full" id="authRegisterBtn" style="display:none;">Create Account</button>
<div class="splash-divider"><span>or</span></div>
<button class="btn btn-secondary btn-full" id="authSkipBtn">Use Offline</button>
</div>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer"></div>
<script src="js/storage.js"></script>
<script src="js/backend.js"></script>
<script src="js/state.js"></script>
<script src="js/api.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
<script src="js/storage.js?v=0.3.4"></script>
<script src="js/backend.js?v=0.3.4"></script>
<script src="js/state.js?v=0.3.4"></script>
<script src="js/api.js?v=0.3.4"></script>
<script src="js/ui.js?v=0.3.4"></script>
<script src="js/admin.js?v=0.3.4"></script>
<script src="js/app.js?v=0.3.4"></script>
</body>
</html>

273
src/js/admin.js Normal file
View File

@@ -0,0 +1,273 @@
// ==========================================
// Admin Panel
// ==========================================
let _adminListenersInit = false;
function initAdmin() {
if (Backend.user && Backend.user.role === 'admin') {
document.getElementById('adminBtn').style.display = '';
} else {
document.getElementById('adminBtn').style.display = 'none';
}
}
function openAdmin() {
document.getElementById('adminModal').classList.add('active');
initAdminListeners();
switchAdminTab('users');
loadAdminUsers();
}
function closeAdmin() {
document.getElementById('adminModal').classList.remove('active');
}
function initAdminListeners() {
if (_adminListenersInit) return;
_adminListenersInit = true;
document.getElementById('adminShowAddUser').addEventListener('click', () => {
document.getElementById('adminAddUserForm').style.display = '';
document.getElementById('adminShowAddUser').style.display = 'none';
});
document.getElementById('adminCancelAddUser').addEventListener('click', () => {
document.getElementById('adminAddUserForm').style.display = 'none';
document.getElementById('adminShowAddUser').style.display = '';
clearAddUserForm();
});
document.getElementById('adminCreateUserBtn').addEventListener('click', handleCreateUser);
document.getElementById('adminResetCancelBtn').addEventListener('click', closeResetDialog);
document.getElementById('adminResetConfirmBtn').addEventListener('click', handleResetPassword);
document.getElementById('adminResetNewPw').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleResetPassword();
});
}
function switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
document.getElementById('adminUsersTab').style.display = tab === 'users' ? '' : 'none';
document.getElementById('adminSettingsTab').style.display = tab === 'settings' ? '' : 'none';
document.getElementById('adminStatsTab').style.display = tab === 'stats' ? '' : 'none';
document.getElementById('adminResetDialog').style.display = 'none';
if (tab === 'settings') loadAdminSettings();
if (tab === 'stats') loadAdminStats();
}
// ── Add User ────────────────────────────────
function clearAddUserForm() {
document.getElementById('adminNewUsername').value = '';
document.getElementById('adminNewEmail').value = '';
document.getElementById('adminNewPassword').value = '';
document.getElementById('adminNewRole').value = 'user';
}
async function handleCreateUser() {
const username = document.getElementById('adminNewUsername').value.trim();
const email = document.getElementById('adminNewEmail').value.trim();
const password = document.getElementById('adminNewPassword').value;
const role = document.getElementById('adminNewRole').value;
if (!username || !email || !password) {
showToast('⚠️ Fill in all fields', 'warning');
return;
}
if (password.length < 8) {
showToast('⚠️ Password must be at least 8 characters', 'warning');
return;
}
try {
await Backend.adminCreateUser(username, email, password, role);
showToast('✅ User "' + username + '" created', 'success');
clearAddUserForm();
document.getElementById('adminAddUserForm').style.display = 'none';
document.getElementById('adminShowAddUser').style.display = '';
loadAdminUsers();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
// ── Users List ──────────────────────────────
async function loadAdminUsers() {
const container = document.getElementById('adminUserList');
container.innerHTML = '<div class="admin-loading">Loading users...</div>';
try {
const data = await Backend.adminListUsers(1, 100);
const users = data.data || [];
if (users.length === 0) {
container.innerHTML = '<div class="admin-empty">No users found</div>';
return;
}
container.innerHTML = users.map(u => {
const isSelf = u.id === Backend.user.id;
const actions = isSelf ? '<span class="admin-you">(you)</span>' : `
<select class="admin-role-select" onchange="changeUserRole('${u.id}', this.value)">
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
<option value="moderator" ${u.role === 'moderator' ? 'selected' : ''}>moderator</option>
</select>
<button class="btn btn-small btn-secondary" onclick="openResetDialog('${u.id}', '${escapeHtml(u.username)}')" title="Reset password">🔑</button>
<button class="btn btn-small ${u.is_active ? 'btn-warning' : 'btn-success'}" onclick="toggleUserActive('${u.id}', ${!u.is_active})">${u.is_active ? 'Disable' : 'Enable'}</button>
<button class="btn btn-small btn-danger" onclick="deleteUser('${u.id}', '${escapeHtml(u.username)}')">Delete</button>
`;
return `
<div class="admin-user-row" data-id="${u.id}">
<div class="admin-user-info">
<span class="admin-user-name">${escapeHtml(u.username)}</span>
<span class="admin-user-email">${escapeHtml(u.email)}</span>
</div>
<div class="admin-user-meta">
<span class="admin-badge admin-badge-${u.role}">${u.role}</span>
<span class="admin-badge ${u.is_active ? 'admin-badge-active' : 'admin-badge-inactive'}">${u.is_active ? 'active' : 'disabled'}</span>
</div>
<div class="admin-user-actions">${actions}</div>
</div>`;
}).join('');
} catch (e) {
container.innerHTML = '<div class="admin-error">Failed to load users: ' + e.message + '</div>';
}
}
async function changeUserRole(userId, role) {
try {
await Backend.adminUpdateUserRole(userId, role);
showToast('✅ Role updated to ' + role, 'success');
} catch (e) {
showToast('❌ ' + e.message, 'error');
loadAdminUsers();
}
}
async function toggleUserActive(userId, isActive) {
try {
await Backend.adminToggleUserActive(userId, isActive);
showToast('✅ User ' + (isActive ? 'enabled' : 'disabled'), 'success');
loadAdminUsers();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function deleteUser(userId, username) {
if (!confirm('Delete user "' + username + '"? This removes all their chats and data.')) return;
try {
await Backend.adminDeleteUser(userId);
showToast('✅ User deleted', 'success');
loadAdminUsers();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
// ── Reset Password ──────────────────────────
let _resetUserId = null;
function openResetDialog(userId, username) {
_resetUserId = userId;
document.getElementById('adminResetTarget').textContent = 'Set new password for: ' + username;
document.getElementById('adminResetNewPw').value = '';
document.getElementById('adminResetDialog').style.display = '';
document.getElementById('adminResetNewPw').focus();
}
function closeResetDialog() {
_resetUserId = null;
document.getElementById('adminResetDialog').style.display = 'none';
}
async function handleResetPassword() {
if (!_resetUserId) return;
const pw = document.getElementById('adminResetNewPw').value;
if (!pw || pw.length < 8) {
showToast('⚠️ Password must be at least 8 characters', 'warning');
return;
}
try {
await Backend.adminResetPassword(_resetUserId, pw);
showToast('✅ Password reset', 'success');
closeResetDialog();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
// ── Settings ────────────────────────────────
async function loadAdminSettings() {
try {
const data = await Backend.adminListSettings();
const settings = data.settings || [];
for (const s of settings) {
if (s.key === 'registration') {
document.getElementById('adminRegToggle').checked = s.value.enabled !== false;
}
if (s.key === 'site') {
document.getElementById('adminSiteName').value = s.value.name || 'Chat Switchboard';
document.getElementById('adminTagline').value = s.value.tagline || 'Multi-Model AI Chat';
}
}
} catch (e) {
showToast('❌ Failed to load settings: ' + e.message, 'error');
}
}
async function saveAdminSettings() {
try {
await Backend.adminUpdateSetting('registration', {
enabled: document.getElementById('adminRegToggle').checked
});
await Backend.adminUpdateSetting('site', {
name: document.getElementById('adminSiteName').value.trim(),
tagline: document.getElementById('adminTagline').value.trim()
});
showToast('✅ Admin settings saved', 'success');
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
// ── Stats ───────────────────────────────────
async function loadAdminStats() {
const container = document.getElementById('adminStats');
container.innerHTML = '<div class="admin-loading">Loading stats...</div>';
try {
const stats = await Backend.adminGetStats();
container.innerHTML = '<div class="admin-stats-grid">' +
statCard(stats.total_users, 'Total Users') +
statCard(stats.active_users, 'Active Users') +
statCard(stats.total_chats, 'Chats') +
statCard(stats.total_messages, 'Messages') +
statCard(stats.api_configs, 'API Configs') +
'</div>';
} catch (e) {
container.innerHTML = '<div class="admin-error">Failed to load stats: ' + e.message + '</div>';
}
}
function statCard(value, label) {
return '<div class="admin-stat-card"><div class="admin-stat-value">' +
(value || 0) + '</div><div class="admin-stat-label">' + label + '</div></div>';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

View File

@@ -10,10 +10,11 @@ async function init() {
Backend.init();
// Auto-detect backend at same origin
let health = null;
if (!Backend.baseUrl) {
const health = await Backend.checkHealth('');
health = await Backend.checkHealth('');
if (health && health.database) {
Backend.baseUrl = window.location.origin; // same origin
Backend.baseUrl = window.location.origin;
Backend.save();
console.log('🔗 Backend detected at same origin');
}
@@ -21,27 +22,68 @@ async function init() {
// If we have saved auth, validate it
if (Backend.accessToken) {
const health = await Backend.checkHealth(Backend.baseUrl);
health = health || await Backend.checkHealth(Backend.baseUrl);
if (!health) {
console.log('⚠️ Backend unreachable, falling back to unmanaged');
Backend.clear();
}
}
// Decide: splash gate or straight to app
if (Backend.baseUrl && !Backend.accessToken) {
// Online but not authenticated → show splash, hide app
showSplashGate(health);
initAuthListeners();
return; // Don't init app yet — authSuccess() will do it
}
// Already authed or no backend → go straight to app
hideSplashGate();
await initApp();
}
async function initApp() {
await loadChats();
initializeEventListeners();
updateQuickModelSelector();
renderChatHistory();
updateConnectionStatus();
// If backend available but not logged in, show auth
if (Backend.baseUrl && !Backend.accessToken) {
showAuthModal();
}
initAdmin();
console.log(`✅ Chat Switchboard ready (${Backend.isManaged ? 'managed' : 'unmanaged'} mode)`);
}
function showSplashGate(health) {
const splash = document.getElementById('splashGate');
const app = document.getElementById('appContainer');
splash.classList.add('visible');
splash.style.display = 'flex'; // Fallback for cached CSS
app.classList.add('app-hidden');
app.style.display = 'none'; // Fallback for cached CSS
// Hide register tab if registration disabled
const registerTab = document.getElementById('authTabRegister');
if (health && health.registration_enabled === false) {
registerTab.style.display = 'none';
} else {
registerTab.style.display = '';
}
}
function hideSplashGate() {
const splash = document.getElementById('splashGate');
const app = document.getElementById('appContainer');
splash.classList.remove('visible');
splash.style.display = 'none'; // Hide splash
app.classList.remove('app-hidden');
app.style.display = ''; // Show app
}
async function authSuccess() {
hideSplashGate();
await initApp();
showToast(`✅ Welcome, ${Backend.user?.username || 'user'}!`, 'success');
}
function initializeEventListeners() {
// Settings modal
document.getElementById('settingsBtn').addEventListener('click', openSettings);
@@ -51,6 +93,12 @@ function initializeEventListeners() {
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings);
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true));
// Profile
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
document.getElementById('profileChangePwForm').style.display = '';
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
// Chat controls
document.getElementById('newChatBtn').addEventListener('click', newChat);
document.getElementById('sendBtn').addEventListener('click', sendMessage);
@@ -97,26 +145,21 @@ function initializeEventListeners() {
document.getElementById('settingsModal').addEventListener('click', function(e) {
if (e.target === this) closeSettings();
});
document.getElementById('authModal').addEventListener('click', function(e) {
if (e.target === this) {
// Only closeable if user has option to skip (unmanaged available)
if (!Backend.baseUrl || Backend.accessToken) {
closeAuthModal();
}
}
});
// Auth modal
document.getElementById('authLoginBtn').addEventListener('click', handleLogin);
document.getElementById('authRegisterBtn').addEventListener('click', handleRegister);
document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login'));
document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register'));
document.getElementById('authCloseBtn').addEventListener('click', closeAuthModal);
document.getElementById('authSkipBtn').addEventListener('click', skipAuth);
// Connection status click
document.getElementById('connectionStatus').addEventListener('click', handleConnectionClick);
// Admin panel
document.getElementById('adminBtn').addEventListener('click', openAdmin);
document.getElementById('adminCloseBtn').addEventListener('click', closeAdmin);
document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => switchAdminTab(tab.dataset.tab));
});
document.getElementById('adminSaveSettings').addEventListener('click', saveAdminSettings);
document.getElementById('adminModal').addEventListener('click', function(e) {
if (e.target === this) closeAdmin();
});
// Global keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === ',') {
@@ -132,20 +175,6 @@ function initializeEventListeners() {
stopGeneration();
} else if (document.getElementById('settingsModal').classList.contains('active')) {
closeSettings();
} else if (document.getElementById('authModal').classList.contains('active')) {
closeAuthModal();
}
}
});
// Enter key in auth form
document.getElementById('authPassword').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
const activeTab = document.querySelector('.auth-tab.active');
if (activeTab && activeTab.id === 'authTabRegister') {
// Focus email field first if registering
} else {
handleLogin();
}
}
});
@@ -176,6 +205,7 @@ function handleSaveSettings() {
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0;
saveSettings();
saveProfile(); // async, fire-and-forget
updateQuickModelSelector();
closeSettings();
showToast('✅ Settings saved', 'success');
@@ -307,18 +337,24 @@ async function regenerateResponse() {
// ── Auth Flow ───────────────────────────────
function showAuthModal() {
document.getElementById('authModal').classList.add('active');
document.getElementById('authLogin').value = '';
document.getElementById('authPassword').value = '';
document.getElementById('authUsername').value = '';
document.getElementById('authEmail').value = '';
document.getElementById('authError').textContent = '';
switchAuthTab('login');
}
let _authListenersInit = false;
function initAuthListeners() {
if (_authListenersInit) return;
_authListenersInit = true;
function closeAuthModal() {
document.getElementById('authModal').classList.remove('active');
document.getElementById('authLoginBtn').addEventListener('click', handleLogin);
document.getElementById('authRegisterBtn').addEventListener('click', handleRegister);
document.getElementById('authTabLogin').addEventListener('click', () => switchAuthTab('login'));
document.getElementById('authTabRegister').addEventListener('click', () => switchAuthTab('register'));
document.getElementById('authSkipBtn').addEventListener('click', skipAuth);
// Enter key in auth forms
document.getElementById('authPassword').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleLogin();
});
document.getElementById('authRegPassword').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleRegister();
});
}
function switchAuthTab(tab) {
@@ -343,11 +379,7 @@ async function handleLogin() {
setAuthLoading(true);
try {
await Backend.login(login, password);
closeAuthModal();
await loadChats();
renderChatHistory();
updateConnectionStatus();
showToast(`✅ Welcome back, ${Backend.user.username}`, 'success');
await authSuccess();
} catch (e) {
document.getElementById('authError').textContent = e.message;
} finally {
@@ -372,11 +404,7 @@ async function handleRegister() {
setAuthLoading(true);
try {
await Backend.register(username, email, password);
closeAuthModal();
await loadChats();
renderChatHistory();
updateConnectionStatus();
showToast(`✅ Welcome, ${Backend.user.username}!`, 'success');
await authSuccess();
} catch (e) {
document.getElementById('authError').textContent = e.message;
} finally {
@@ -393,10 +421,10 @@ function setAuthLoading(loading) {
});
}
function skipAuth() {
closeAuthModal();
async function skipAuth() {
Backend.clear();
updateConnectionStatus();
hideSplashGate();
await initApp();
showToast('📴 Running in offline mode', 'success');
}
@@ -406,7 +434,7 @@ function updateConnectionStatus() {
const el = document.getElementById('connectionStatus');
if (Backend.isManaged) {
el.className = 'connection-status managed';
el.innerHTML = `<span class="status-dot"></span> ${Backend.user?.username || 'Connected'}`;
el.innerHTML = `<span class="status-dot"></span> ${Backend.user?.display_name || Backend.user?.username || 'Connected'}`;
el.title = 'Managed mode click to sign out';
} else if (Backend.baseUrl) {
el.className = 'connection-status available';
@@ -425,14 +453,13 @@ async function handleConnectionClick() {
await Backend.logout();
State.chats = [];
State.currentChatId = null;
loadChats();
newChat();
renderChatHistory();
updateConnectionStatus();
showSplashGate(null);
initAuthListeners();
showToast('👋 Signed out', 'success');
}
} else if (Backend.baseUrl) {
showAuthModal();
showSplashGate(null);
initAuthListeners();
}
}

View File

@@ -201,6 +201,99 @@ const Backend = {
});
},
// ── Profile & Settings ──────────────────
async getProfile() {
return this._authedFetch('/api/v1/profile');
},
async updateProfile(updates) {
return this._authedFetch('/api/v1/profile', {
method: 'PUT',
body: JSON.stringify(updates)
});
},
async changePassword(currentPassword, newPassword) {
return this._authedFetch('/api/v1/profile/password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
},
async getSettings() {
return this._authedFetch('/api/v1/settings');
},
async updateSettings(settings) {
return this._authedFetch('/api/v1/settings', {
method: 'PUT',
body: JSON.stringify(settings)
});
},
// ── Admin ───────────────────────────────
async adminListUsers(page = 1, perPage = 50) {
return this._authedFetch(`/api/v1/admin/users?page=${page}&per_page=${perPage}`);
},
async adminCreateUser(username, email, password, role) {
return this._authedFetch('/api/v1/admin/users', {
method: 'POST',
body: JSON.stringify({ username, email, password, role })
});
},
async adminResetPassword(userId, newPassword) {
return this._authedFetch(`/api/v1/admin/users/${userId}/reset-password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword })
});
},
async adminUpdateUserRole(userId, role) {
return this._authedFetch(`/api/v1/admin/users/${userId}/role`, {
method: 'PUT',
body: JSON.stringify({ role })
});
},
async adminToggleUserActive(userId, isActive) {
return this._authedFetch(`/api/v1/admin/users/${userId}/active`, {
method: 'PUT',
body: JSON.stringify({ is_active: isActive })
});
},
async adminDeleteUser(userId) {
return this._authedFetch(`/api/v1/admin/users/${userId}`, {
method: 'DELETE'
});
},
async adminListSettings() {
return this._authedFetch('/api/v1/admin/settings');
},
async adminGetSetting(key) {
return this._authedFetch(`/api/v1/admin/settings/${key}`);
},
async adminUpdateSetting(key, value) {
return this._authedFetch(`/api/v1/admin/settings/${key}`, {
method: 'PUT',
body: JSON.stringify(value)
});
},
async adminGetStats() {
return this._authedFetch('/api/v1/admin/stats');
},
// ── Models ──────────────────────────────
async listAllModels() {

View File

@@ -88,9 +88,88 @@ function updateQuickModelSelector() {
function openSettings() {
updateSettingsUI();
updateProfileSection();
document.getElementById('settingsModal').classList.add('active');
}
function updateProfileSection() {
const profileSection = document.getElementById('profileSection');
const unmanagedSettings = document.getElementById('unmanagedSettings');
if (Backend.isManaged) {
profileSection.style.display = '';
unmanagedSettings.style.display = 'none';
loadProfileIntoSettings();
} else {
profileSection.style.display = 'none';
unmanagedSettings.style.display = '';
}
// Always reset password form
document.getElementById('profileChangePwForm').style.display = 'none';
}
async function loadProfileIntoSettings() {
try {
const profile = await Backend.getProfile();
document.getElementById('profileDisplayName').value = profile.display_name || '';
document.getElementById('profileEmail').value = profile.email || '';
} catch (e) {
console.warn('Failed to load profile:', e);
}
}
async function saveProfile() {
if (!Backend.isManaged) return;
const displayName = document.getElementById('profileDisplayName').value.trim();
const email = document.getElementById('profileEmail').value.trim();
const updates = {};
if (displayName !== (Backend.user.display_name || '')) {
updates.display_name = displayName || null;
}
if (email && email !== Backend.user.email) {
updates.email = email;
}
if (Object.keys(updates).length === 0) return;
try {
const profile = await Backend.updateProfile(updates);
// Update local user state
if (profile.display_name !== undefined) Backend.user.display_name = profile.display_name;
if (profile.email) Backend.user.email = profile.email;
Backend.save();
showToast('✅ Profile updated', 'success');
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function handleChangePassword() {
const current = document.getElementById('profileCurrentPw').value;
const newPw = document.getElementById('profileNewPw').value;
if (!current || !newPw) {
showToast('⚠️ Fill in both fields', 'warning');
return;
}
if (newPw.length < 8) {
showToast('⚠️ New password must be at least 8 characters', 'warning');
return;
}
try {
await Backend.changePassword(current, newPw);
showToast('✅ Password updated', 'success');
document.getElementById('profileCurrentPw').value = '';
document.getElementById('profileNewPw').value = '';
document.getElementById('profileChangePwForm').style.display = 'none';
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
function closeSettings() {
document.getElementById('settingsModal').classList.remove('active');
}