450 lines
16 KiB
JavaScript
450 lines
16 KiB
JavaScript
// hyprcrd relay UI
|
|
|
|
let me = null;
|
|
let allTenants = [];
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
|
|
function toast(msg, isError = false) {
|
|
const t = $('toast');
|
|
t.textContent = msg;
|
|
t.classList.toggle('err', isError);
|
|
t.classList.add('show');
|
|
setTimeout(() => t.classList.remove('show'), 2200);
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(path, {
|
|
method: opts.method || 'GET',
|
|
headers: opts.body ? { 'Content-Type': 'application/json' } : {},
|
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
credentials: 'same-origin',
|
|
});
|
|
return res.json().catch(() => ({ error: 'invalid response' }));
|
|
}
|
|
|
|
function showView(name) {
|
|
// If the user hasn't changed their password yet, force them to that view
|
|
// unless they're currently in setup/login/change-password (admin/anon flows)
|
|
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
|
name = 'change-password';
|
|
}
|
|
// Block admin view on mobile — even if someone tries programmatically
|
|
if (name === 'admin' && window.matchMedia('(max-width: 700px)').matches) {
|
|
name = 'compose';
|
|
}
|
|
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
|
|
$('view-' + v)?.classList.add('hidden');
|
|
});
|
|
$('view-' + name)?.classList.remove('hidden');
|
|
|
|
if (['compose', 'account', 'admin'].includes(name)) {
|
|
$('nav-buttons').classList.remove('hidden');
|
|
$('nav-buttons').style.display = 'flex';
|
|
$('nav-compose').classList.toggle('active', name === 'compose');
|
|
$('nav-account').classList.toggle('active', name === 'account');
|
|
$('nav-admin').classList.toggle('active', name === 'admin');
|
|
} else {
|
|
// Setup, login, and forced change-password hide all nav
|
|
$('nav-buttons').classList.add('hidden');
|
|
}
|
|
|
|
if (name === 'compose') {
|
|
loadTenants().then(() => populateRecipientPicker());
|
|
loadInbox();
|
|
$('compose-body')?.focus();
|
|
} else if (name === 'admin') {
|
|
loadUsers();
|
|
} else if (name === 'account') {
|
|
loadAccountSecret();
|
|
} else if (name === 'change-password') {
|
|
$('cp-current')?.focus();
|
|
$('cp-err').textContent = '';
|
|
}
|
|
}
|
|
|
|
async function doChangePassword() {
|
|
const current = $('cp-current').value;
|
|
const newPw = $('cp-new').value;
|
|
const newPw2 = $('cp-new2').value;
|
|
const err = $('cp-err');
|
|
err.textContent = '';
|
|
if (!current || !newPw) { err.textContent = 'all fields required'; return; }
|
|
if (newPw.length < 4) { err.textContent = 'new password too short'; return; }
|
|
if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; }
|
|
if (current === newPw) { err.textContent = 'new password must differ from current'; return; }
|
|
|
|
const res = await api('api/account/password', { method: 'POST', body: { current, new: newPw } });
|
|
if (res.ok) {
|
|
toast('password changed');
|
|
$('cp-current').value = '';
|
|
$('cp-new').value = '';
|
|
$('cp-new2').value = '';
|
|
// After password change, route based on whether they have a secret
|
|
const status = await refreshMe();
|
|
if (!status.has_secret) {
|
|
showView('account');
|
|
toast('now set your client secret');
|
|
} else {
|
|
showView('compose');
|
|
}
|
|
} else {
|
|
err.textContent = res.error || 'failed';
|
|
}
|
|
}
|
|
|
|
// ── Setup ──
|
|
|
|
async function doSetup() {
|
|
const username = $('setup-username').value.trim();
|
|
const password = $('setup-password').value;
|
|
const superSecret = $('setup-super-secret').value;
|
|
const err = $('setup-err');
|
|
err.textContent = '';
|
|
if (!username) { err.textContent = 'username required'; return; }
|
|
if (password.length < 4) { err.textContent = 'password too short'; return; }
|
|
if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; }
|
|
const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } });
|
|
if (res.ok) {
|
|
await refreshMe();
|
|
showView('account');
|
|
toast('admin created — now set your client secret');
|
|
} else {
|
|
err.textContent = res.error || 'failed';
|
|
}
|
|
}
|
|
|
|
// ── Login ──
|
|
|
|
async function doLogin() {
|
|
const username = $('login-username').value.trim();
|
|
const password = $('login-password').value;
|
|
const err = $('login-err');
|
|
err.textContent = '';
|
|
if (!username || !password) { err.textContent = 'username and password required'; return; }
|
|
const res = await api('api/login', { method: 'POST', body: { username, password } });
|
|
if (res.ok) {
|
|
const status = await refreshMe();
|
|
routePostLogin(status);
|
|
} else {
|
|
err.textContent = res.error || 'login failed';
|
|
}
|
|
}
|
|
|
|
// Decide which view to land on after a successful login / on initial load.
|
|
// Order: forced password change → set client secret → compose.
|
|
function routePostLogin(status) {
|
|
if (!status.password_changed) {
|
|
showView('change-password');
|
|
// Pre-fill the current-password input from what they just typed if
|
|
// we're coming from the login form (otherwise it's empty)
|
|
const lp = $('login-password');
|
|
if (lp && lp.value) {
|
|
$('cp-current').value = lp.value;
|
|
lp.value = '';
|
|
}
|
|
toast('change your password before continuing');
|
|
} else if (!status.has_secret) {
|
|
showView('account');
|
|
toast('set your client secret to start using the relay');
|
|
} else {
|
|
showView('compose');
|
|
}
|
|
}
|
|
|
|
|
|
async function doLogout() {
|
|
await api('api/logout', { method: 'POST' });
|
|
me = null;
|
|
$('nav-user').textContent = '';
|
|
$('nav-admin').classList.add('hidden');
|
|
showView('login');
|
|
}
|
|
|
|
async function refreshMe() {
|
|
const status = await api('api/status');
|
|
if (status.logged_in) {
|
|
me = {
|
|
username: status.username,
|
|
is_admin: status.is_admin,
|
|
has_secret: status.has_secret,
|
|
password_changed: status.password_changed,
|
|
};
|
|
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
|
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
|
$('header-status').textContent = (status.inbox_count || 0) + ' in inbox';
|
|
}
|
|
return status;
|
|
}
|
|
|
|
// ── Compose ──
|
|
|
|
async function loadTenants() {
|
|
const res = await api('api/tenants');
|
|
if (res.tenants) allTenants = res.tenants;
|
|
}
|
|
|
|
function populateRecipientPicker() {
|
|
const sel = $('compose-recipient');
|
|
const field = $('compose-recipient-field');
|
|
sel.innerHTML = '';
|
|
|
|
// Always include "you" first as the default recipient (the value
|
|
// the form submits even when the picker is hidden)
|
|
if (me) {
|
|
const opt = document.createElement('option');
|
|
opt.value = me.username;
|
|
opt.textContent = `${me.username} (you)`;
|
|
sel.appendChild(opt);
|
|
}
|
|
for (const t of allTenants) {
|
|
if (me && t.username === me.username) continue;
|
|
const opt = document.createElement('option');
|
|
opt.value = t.username;
|
|
opt.textContent = t.username;
|
|
sel.appendChild(opt);
|
|
}
|
|
|
|
// Solo tenant → no choice to make. Hide the picker entirely so
|
|
// the compose form is just title + body.
|
|
if (field) {
|
|
field.classList.toggle('hidden', allTenants.length <= 1);
|
|
}
|
|
}
|
|
|
|
function clearCompose() {
|
|
$('compose-title').value = '';
|
|
$('compose-body').value = '';
|
|
$('compose-body').focus();
|
|
setComposeStatus('');
|
|
}
|
|
|
|
function setComposeStatus(text, kind = '') {
|
|
const el = $('compose-status');
|
|
el.textContent = text;
|
|
el.className = 'status ' + kind;
|
|
}
|
|
|
|
async function sendCompose() {
|
|
const title = $('compose-title').value;
|
|
const body = $('compose-body').value;
|
|
const recipient = $('compose-recipient').value;
|
|
if (!body.trim()) { setComposeStatus('body required', 'err'); return; }
|
|
setComposeStatus('sending...');
|
|
const res = await api('api/messages', { method: 'POST', body: { title, body, recipient } });
|
|
if (res.ok) {
|
|
setComposeStatus(`sent to ${res.recipient}`, 'ok');
|
|
$('compose-title').value = '';
|
|
$('compose-body').value = '';
|
|
$('compose-body').focus();
|
|
loadInbox();
|
|
refreshMe();
|
|
} else {
|
|
setComposeStatus(res.error || 'failed', 'err');
|
|
}
|
|
}
|
|
|
|
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
|
|
|
async function loadInbox() {
|
|
const status = await api('api/status');
|
|
$('inbox-count').textContent = status.inbox_count || 0;
|
|
const list = $('inbox-list');
|
|
if (!status.inbox_count) {
|
|
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
|
|
} else {
|
|
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</div>`;
|
|
}
|
|
}
|
|
|
|
// ── Admin: users ──
|
|
|
|
async function loadUsers() {
|
|
const res = await api('api/users');
|
|
if (res.error) { toast(res.error, true); return; }
|
|
const users = res.users || [];
|
|
$('users-count').textContent = users.length;
|
|
const list = $('users-list');
|
|
if (!users.length) {
|
|
list.innerHTML = '<div class="empty">no users yet</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = users.map(u => `
|
|
<div class="user-row" data-id="${u.id}">
|
|
<i class="ph ph-user-circle" style="font-size:20px;color:var(--accent);"></i>
|
|
<div class="info">
|
|
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
|
<div class="meta">created ${u.created_at}</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
|
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function createUser() {
|
|
const username = $('new-username').value.trim();
|
|
const password = $('new-password').value;
|
|
const isAdmin = $('new-is-admin').checked;
|
|
const status = $('create-user-status');
|
|
status.textContent = 'creating...';
|
|
status.className = 'status';
|
|
const res = await api('api/users', { method: 'POST', body: { username, password, is_admin: isAdmin } });
|
|
if (res.ok) {
|
|
status.textContent = 'created — they set their own client secret on first login';
|
|
status.className = 'status ok';
|
|
$('new-username').value = '';
|
|
$('new-password').value = '';
|
|
$('new-is-admin').checked = false;
|
|
loadUsers();
|
|
setTimeout(() => { status.textContent = ''; }, 3000);
|
|
} else {
|
|
status.textContent = res.error || 'failed';
|
|
status.className = 'status err';
|
|
}
|
|
}
|
|
|
|
async function resetUserPassword(id, username) {
|
|
const newPw = prompt(`new password for ${username} (min 4 chars):`);
|
|
if (!newPw || newPw.length < 4) { toast('password too short', true); return; }
|
|
const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } });
|
|
if (res.ok) toast('password updated');
|
|
else toast(res.error || 'failed', true);
|
|
}
|
|
|
|
async function deleteUser(id, username) {
|
|
if (!confirm(`Delete user "${username}"? All their messages and attachments will be removed.`)) return;
|
|
const res = await api('api/users/' + id, { method: 'DELETE' });
|
|
if (res.ok) { toast('user deleted'); loadUsers(); }
|
|
else toast(res.error || 'failed', true);
|
|
}
|
|
|
|
// ── Account: client secret management ──
|
|
|
|
let currentAccountSecret = null;
|
|
|
|
async function loadAccountSecret() {
|
|
setAccountStatus('');
|
|
const res = await api('api/account/secret');
|
|
if (res.has_secret) {
|
|
currentAccountSecret = res.secret;
|
|
$('account-current').classList.remove('hidden');
|
|
$('account-no-secret').classList.add('hidden');
|
|
$('account-secret-display').type = 'password';
|
|
$('account-secret-display').value = currentAccountSecret;
|
|
$('account-secret-toggle').innerHTML = '<i class="ph ph-eye"></i> show';
|
|
} else {
|
|
currentAccountSecret = null;
|
|
$('account-current').classList.add('hidden');
|
|
$('account-no-secret').classList.remove('hidden');
|
|
}
|
|
$('account-secret-input').value = '';
|
|
}
|
|
|
|
function toggleAccountSecret() {
|
|
const inp = $('account-secret-display');
|
|
const btn = $('account-secret-toggle');
|
|
if (inp.type === 'password') {
|
|
inp.type = 'text';
|
|
btn.innerHTML = '<i class="ph ph-eye-slash"></i> hide';
|
|
} else {
|
|
inp.type = 'password';
|
|
btn.innerHTML = '<i class="ph ph-eye"></i> show';
|
|
}
|
|
}
|
|
|
|
async function copyAccountSecret() {
|
|
if (!currentAccountSecret) return;
|
|
try {
|
|
await navigator.clipboard.writeText(currentAccountSecret);
|
|
toast('secret copied to clipboard');
|
|
} catch {
|
|
toast('copy failed — show and copy manually', true);
|
|
}
|
|
}
|
|
|
|
async function saveAccountSecret() {
|
|
const secret = $('account-secret-input').value;
|
|
if (secret.length < 8) { setAccountStatus('secret needs at least 8 chars', 'err'); return; }
|
|
setAccountStatus('saving...');
|
|
const res = await api('api/account/secret', { method: 'POST', body: { secret } });
|
|
if (res.ok) {
|
|
setAccountStatus('saved', 'ok');
|
|
await loadAccountSecret();
|
|
await refreshMe();
|
|
setTimeout(() => setAccountStatus(''), 2000);
|
|
} else {
|
|
setAccountStatus(res.error || 'failed', 'err');
|
|
}
|
|
}
|
|
|
|
async function generateAccountSecret() {
|
|
if (currentAccountSecret) {
|
|
if (!confirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.')) return;
|
|
}
|
|
setAccountStatus('generating...');
|
|
const res = await api('api/account/secret', { method: 'POST', body: { generate: true } });
|
|
if (res.ok) {
|
|
setAccountStatus('generated — copy it to your hyprcrd clients now', 'ok');
|
|
await loadAccountSecret();
|
|
await refreshMe();
|
|
// Auto-show the new secret so user can copy
|
|
$('account-secret-display').type = 'text';
|
|
$('account-secret-toggle').innerHTML = '<i class="ph ph-eye-slash"></i> hide';
|
|
} else {
|
|
setAccountStatus(res.error || 'failed', 'err');
|
|
}
|
|
}
|
|
|
|
async function clearAccountSecret() {
|
|
if (!confirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.')) return;
|
|
const res = await api('api/account/secret', { method: 'DELETE' });
|
|
if (res.ok) {
|
|
setAccountStatus('cleared', 'ok');
|
|
await loadAccountSecret();
|
|
await refreshMe();
|
|
} else {
|
|
setAccountStatus(res.error || 'failed', 'err');
|
|
}
|
|
}
|
|
|
|
function setAccountStatus(text, kind = '') {
|
|
const el = $('account-status');
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = 'status ' + kind;
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|
|
|
|
// ── Init ──
|
|
|
|
(async () => {
|
|
const status = await api('api/status');
|
|
if (status.setup) {
|
|
showView('setup');
|
|
} else if (!status.logged_in) {
|
|
showView('login');
|
|
} else {
|
|
await refreshMe();
|
|
routePostLogin(status);
|
|
}
|
|
})();
|
|
|
|
// Keyboard
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
if (document.activeElement?.id === 'login-password') { e.preventDefault(); doLogin(); }
|
|
if (document.activeElement?.id === 'setup-secret') { e.preventDefault(); doSetup(); }
|
|
if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); }
|
|
}
|
|
});
|