feat: provision private Hermes assistants for authenticated Hope accounts
This commit is contained in:
1 parent
da65da68b3
commit
2aedea84b5
33 files changed
+3063
-1743
No files matched your search
+88
@@ -0,0 +1,88 @@
|
||||
// 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; });
|
||||
})();
|
||||
Reference in new issue
Block a user