Files
hope-voice-api/web/auth.js
T

89 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; });
})();