// Cookie sessions only: never retain the bearer returned by login. (() => { let user = null, epoch = 0, timer = null, refreshing = false, provisioning = false; const listeners = [], el = id => document.getElementById(id); const say = (id, text) => { el(id).textContent = text; }; function setUser(next) { const changed = String(user?.id) !== String(next?.id); user = next; if (changed) epoch++; el('loginGate').hidden = !!user; el('authenticatedApp').hidden = !user; el('sessionActions').hidden = !user; el('adminBtn').hidden = user?.role !== 'admin'; el('identityName').textContent = user?.display_name || user?.username || ''; el('identityChip').setAttribute('aria-label', `Current account: ${user?.display_name || user?.username || ''}`); say('privacyIdentity', user ? `Your memory stays in ${user.display_name || user.username}’s private profile.` : ''); say('accountStatus', !user || user.status === 'ready' ? '' : `${user.status || 'Provisioning'}: ${user.error || 'Your private assistant is being prepared. Conversation is available when ready.'}`); if (!user || user.role !== 'admin') { el('adminSection').hidden = true; el('accountDialog').close(); } clearTimeout(timer); listeners.forEach(fn => fn(user, changed)); if (user) poll(); } async function request(path, options = {}) { const requestEpoch = epoch; const response = await fetch(path, { ...options, credentials: 'same-origin', headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers } }); if (response.status === 401 && requestEpoch === epoch) { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); } const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(typeof data.error === 'string' ? data.error : typeof data.detail === 'string' ? data.detail : `Request failed (${response.status}).`); return data; } function poll() { timer = setTimeout(async () => { const version = epoch; try { const data = await request('/api/auth/me'); if (version !== epoch) return; setUser(data.user); if (user?.role === 'admin' && !el('adminSection').hidden) await refreshUsers(); } catch (error) { if (user) { say('accountStatus', 'Could not refresh account status. Retrying…'); poll(); } } }, user?.status === 'ready' && !provisioning ? 10000 : 2500); } el('loginForm').addEventListener('submit', async event => { event.preventDefault(); el('loginSubmit').disabled = true; say('loginMessage', 'Signing in…'); try { const data = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ username: el('loginUsername').value.trim(), password: el('loginPassword').value }) }); setUser(data.user); say('loginMessage', ''); } catch (error) { say('loginMessage', error.message); } finally { el('loginPassword').value = ''; el('loginSubmit').disabled = false; } }); async function logout() { el('logoutBtn').disabled = true; setUser(null); try { await request('/api/auth/logout', { method: 'POST' }); say('loginMessage', 'Signed out.'); el('retryLogout').hidden = true; } catch (error) { say('loginMessage', 'Disconnected locally, but server sign-out failed. Retry before leaving a shared device.'); el('retryLogout').hidden = false; } finally { el('logoutBtn').disabled = false; el('loginUsername').focus(); } } el('logoutBtn').addEventListener('click', logout); el('retryLogout').addEventListener('click', logout); function cell(text) { const td = document.createElement('td'); td.textContent = text || '—'; return td; } function action(label, handler) { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn btn-secondary'; button.textContent = label; button.addEventListener('click', handler); return button; } async function refreshUsers() { if (refreshing || user?.role !== 'admin') return; refreshing = true; const version = epoch; try { const data = await request('/api/admin/users'); if (version !== epoch || user?.role !== 'admin') return; provisioning = (data.users || []).some(account => !['ready', 'error'].includes(account.status)); clearTimeout(timer); if (user) poll(); el('usersBody').replaceChildren(); for (const account of data.users || []) { const row = document.createElement('tr'); row.append(cell(`${account.display_name || account.username} (@${account.username})`), cell(account.role), cell(account.status), cell(account.container_name), cell(account.error)); const actions = cell(''); actions.textContent = ''; actions.className = 'user-actions'; actions.append(action('Reset password', () => openDialog(account, 'password')), action('Delete', () => openDialog(account, 'delete'))); row.append(actions); el('usersBody').append(row); } say('adminMessage', 'Account and container status refresh automatically.'); } catch (error) { say('adminMessage', error.message); } finally { refreshing = false; } } el('adminBtn').addEventListener('click', () => { el('adminSection').hidden = !el('adminSection').hidden; el('adminBtn').setAttribute('aria-expanded', String(!el('adminSection').hidden)); if (!el('adminSection').hidden) { refreshUsers(); el('adminHeading').focus(); } }); el('refreshUsers').addEventListener('click', refreshUsers); el('createUserForm').addEventListener('submit', async event => { event.preventDefault(); el('createUserSubmit').disabled = true; const version = epoch; try { await request('/api/admin/users', { method: 'POST', body: JSON.stringify({ username: el('newUsername').value.trim(), display_name: el('newDisplayName').value.trim(), password: el('newPassword').value, role: el('newRole').value }) }); if (version !== epoch) return; el('createUserForm').reset(); await refreshUsers(); say('createUserMessage', 'Account created. Watch its provisioning status above.'); } catch (error) { say('createUserMessage', error.message); } finally { el('newPassword').value = ''; el('createUserSubmit').disabled = false; } }); let selected = null, mode = null; function openDialog(account, nextMode) { selected = account; mode = nextMode; say('accountDialogTitle', mode === 'delete' ? `Delete @${account.username}?` : `Reset password for @${account.username}`); say('accountDialogDescription', mode === 'delete' ? 'This revokes access and removes the private assistant container. Retained private data is not erased.' : 'Existing sessions will be revoked. Share the new password securely.'); el('resetPasswordField').hidden = mode !== 'password'; el('resetPassword').required = mode === 'password'; el('confirmAccountAction').textContent = mode === 'delete' ? 'Delete account' : 'Reset password'; say('accountDialogMessage', ''); el('accountDialog').showModal(); } el('cancelAccountAction').addEventListener('click', () => el('accountDialog').close()); el('accountDialog').addEventListener('close', () => { el('resetPassword').value = ''; selected = null; }); el('accountActionForm').addEventListener('submit', async event => { event.preventDefault(); if (!selected || user?.role !== 'admin') return; el('confirmAccountAction').disabled = true; const id = encodeURIComponent(selected.id); try { await request(`/api/admin/users/${id}${mode === 'password' ? '/password' : ''}`, mode === 'password' ? { method: 'POST', body: JSON.stringify({ password: el('resetPassword').value }) } : { method: 'DELETE' }); el('accountDialog').close(); await refreshUsers(); } catch (error) { say('accountDialogMessage', error.message); } finally { el('resetPassword').value = ''; el('confirmAccountAction').disabled = false; } }); window.HopeAuth = { get user() { return user; }, request, onChange(fn) { listeners.push(fn); fn(user, true); }, signOut() { setUser(null); say('loginMessage', 'Your session ended. Sign in again to continue.'); } }; request('/api/auth/me').then(data => setUser(data.user)).catch(error => say('loginMessage', error.message)).finally(() => { el('loginSubmit').disabled = false; }); })();