My account page
The signed-in user's profile, password and own app tokens move from the Settings card to their own page at #/account, ready for more user functions. The sidebar footer links to it with the user's name. Users can now set their own note; the page header shows when and from where the session started, which the server now records. Settings keeps the Users table, where your own row links to My account.
This commit is contained in:
@@ -463,6 +463,41 @@
|
||||
h('div', { class: 'xaxis' }, h('span', null, '24 h ago'), h('span', null, '12 h ago'), h('span', null, 'now')));
|
||||
}
|
||||
|
||||
// pairDialog creates an API token and shows it once, with the pairing QR
|
||||
// code for the iOS app; onCreated runs after the token is saved.
|
||||
function pairDialog(onCreated) {
|
||||
const nm = h('input', { id: 'tn', value: 'iPhone app' });
|
||||
const sc = h('select', { id: 'ts' }, h('option', { value: 'rw' }, 'Full access'), h('option', { value: 'ro' }, 'Read only'));
|
||||
const e = h('p', { class: 'err-text' });
|
||||
const d = dialog((close) => {
|
||||
const body = h('form', { class: 'dlg', onSubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const r = await api('POST', '/tokens', { name: nm.value, scope: sc.value });
|
||||
body.replaceChildren(
|
||||
h('h2', null, 'Scan with the iOS app'),
|
||||
h('div', { class: 'notice' }, 'The token is shown only now. Only a hash is stored on the server.'),
|
||||
h('div', { class: 'qrrow' },
|
||||
h('img', { class: 'qr', src: r.qr, alt: 'Pairing QR code' }),
|
||||
h('div', { class: 'col' },
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => copy(r.pairing) }, 'Copy pairing code'),
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => copy(r.token) }, 'Copy token'))),
|
||||
h('pre', { class: 'code' }, r.token),
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done')));
|
||||
onCreated();
|
||||
} catch (x) { e.textContent = x.message; }
|
||||
} },
|
||||
h('h2', null, 'New API token'),
|
||||
h('p', null, 'For the iOS app or scripts. The pairing QR code holds the server address, the token and the certificate fingerprint.'),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'tn' }, 'Name'), nm),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'ts' }, 'Access'), sc),
|
||||
e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Create token')));
|
||||
return body;
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---------- shell, router ----------
|
||||
|
||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
||||
@@ -478,9 +513,12 @@
|
||||
srvBox,
|
||||
NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))),
|
||||
h('div', { class: 'foot' },
|
||||
'Signed in as ', me.name, ' · ',
|
||||
h('button', { type: 'button', onClick: logout }, 'Sign out'),
|
||||
h('br'), 'v' + me.version));
|
||||
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
|
||||
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
|
||||
h('span', null, h('strong', null, me.name), h('span', null, 'My account')))),
|
||||
h('div', { class: 'footrow' },
|
||||
h('button', { type: 'button', onClick: logout }, 'Sign out'),
|
||||
h('span', null, 'v' + me.version))));
|
||||
main = h('main', { class: 'main', id: 'main' });
|
||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||
refreshSide();
|
||||
@@ -516,6 +554,7 @@
|
||||
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
||||
[/^#\/server$/, '#/server', viewServer],
|
||||
[/^#\/settings$/, '#/settings', viewSettings],
|
||||
[/^#\/account$/, '#/account', viewAccount],
|
||||
];
|
||||
|
||||
async function render() {
|
||||
@@ -1228,6 +1267,103 @@
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
// ---------- my account ----------
|
||||
|
||||
async function viewAccount(wrap) {
|
||||
if (!me.isAdmin) {
|
||||
fill(wrap, h('h1', null, 'My account'), h('div', { class: 'notice' }, 'API tokens have no account page. Sign in to the web interface.'));
|
||||
return;
|
||||
}
|
||||
const [m, tk] = await Promise.all([api('GET', '/auth/me'), api('GET', '/tokens')]);
|
||||
|
||||
// profile
|
||||
const uname = h('input', { id: 'un', value: m.username, autocomplete: 'username', autocapitalize: 'none', required: true });
|
||||
const note = h('input', { id: 'nt', value: m.note || '', autocomplete: 'off' });
|
||||
const profErr = h('p', { class: 'err-text', role: 'alert' });
|
||||
const saveProfile = async (e) => {
|
||||
e.preventDefault();
|
||||
profErr.textContent = '';
|
||||
const body = {};
|
||||
if (uname.value.trim() !== m.username) body.username = uname.value.trim();
|
||||
if (note.value.trim() !== (m.note || '')) body.note = note.value.trim();
|
||||
if (!Object.keys(body).length) { toast('Nothing changed'); return; }
|
||||
try {
|
||||
await api('PATCH', '/users/' + m.id, body);
|
||||
toast('Profile saved');
|
||||
me = await api('GET', '/auth/me');
|
||||
main = null; // the sidebar shows the name
|
||||
render();
|
||||
} catch (x) { profErr.textContent = x.message; }
|
||||
};
|
||||
|
||||
// password
|
||||
const cur = h('input', { id: 'pc', type: 'password', autocomplete: 'current-password', required: true });
|
||||
const p1 = h('input', { id: 'p1', type: 'password', autocomplete: 'new-password', placeholder: 'At least 12 characters', required: true });
|
||||
const p2 = h('input', { id: 'p2', type: 'password', autocomplete: 'new-password', required: true });
|
||||
const pwErr = h('p', { class: 'err-text', role: 'alert' });
|
||||
const savePw = async (e) => {
|
||||
e.preventDefault();
|
||||
pwErr.textContent = '';
|
||||
if (p1.value !== p2.value) { pwErr.textContent = 'The new passwords do not match'; return; }
|
||||
try {
|
||||
await api('POST', '/auth/password', { current: cur.value, new: p1.value });
|
||||
toast('Password changed. Other browsers are signed out.');
|
||||
cur.value = p1.value = p2.value = '';
|
||||
} catch (x) { pwErr.textContent = x.message; }
|
||||
};
|
||||
|
||||
// my tokens
|
||||
const tbody = h('tbody');
|
||||
const drawTokens = (tokens) => {
|
||||
const mine = tokens.filter((t) => t.ownerId === m.id);
|
||||
tbody.replaceChildren(...(mine.length ? mine.map((t) => h('tr', null,
|
||||
h('td', null, t.name),
|
||||
h('td', null, h('span', { class: 'badge' }, t.scope === 'ro' ? 'Read only' : 'Full access')),
|
||||
h('td', null, fmtDate(t.created)),
|
||||
h('td', null, t.lastUsed ? ago(t.lastUsed.at) + ' · ' + t.lastUsed.ip : 'Not since restart'),
|
||||
h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn danger small', onClick: () => revoke(t) }, 'Revoke'))))
|
||||
: [h('tr', null, h('td', { colspan: '5', class: 'muted' }, 'You have no app tokens.'))]));
|
||||
};
|
||||
drawTokens(tk.tokens);
|
||||
const reloadTokens = async () => drawTokens((await api('GET', '/tokens')).tokens);
|
||||
const revoke = async (t) => {
|
||||
if (!await confirmDialog({ title: 'Revoke ' + t.name + '?', text: 'Apps using this token are signed out immediately.', ok: 'Revoke', danger: true })) return;
|
||||
try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); }
|
||||
};
|
||||
|
||||
const since = m.session ? 'since ' + fmtStamp(m.session.started) + ' from ' + m.session.ip : '';
|
||||
fill(wrap,
|
||||
h('div', null, h('h1', null, 'My account'),
|
||||
h('p', { class: 'sub' }, ['Signed in as ' + m.username, since, m.created ? 'account created ' + fmtDate(m.created) : ''].filter(Boolean).join(' · '))),
|
||||
|
||||
h('form', { class: 'card', onSubmit: saveProfile, 'aria-labelledby': 'prof' },
|
||||
h('h2', { id: 'prof' }, 'Profile'),
|
||||
h('p', { class: 'lead' }, 'Your username is what you sign in with. Other admins see the note in the Users list.'),
|
||||
h('div', { class: 'grid' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'un' }, 'Username'), uname, h('span', { class: 'hint' }, 'Letters, numbers, . @ _ - · max 32')),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'nt' }, 'Note'), note)),
|
||||
profErr,
|
||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save profile'))),
|
||||
|
||||
h('form', { class: 'card', onSubmit: savePw, 'aria-labelledby': 'pw' },
|
||||
h('h2', { id: 'pw' }, 'Password'),
|
||||
h('p', { class: 'lead' }, 'Changing it signs you out in other browsers. Your app tokens keep working.'),
|
||||
h('div', { class: 'grid' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'pc' }, 'Current password'), cur),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'p1' }, 'New password'), p1),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'p2' }, 'Repeat new password'), p2)),
|
||||
pwErr,
|
||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Change password'))),
|
||||
|
||||
h('section', { class: 'card flush', 'aria-labelledby': 'mytk' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h2', { id: 'mytk' }, 'My app tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Tokens you created for the iOS app and scripts. All tokens are listed under Settings → API tokens.')),
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: () => pairDialog(reloadTokens) }, 'Pair iOS app')),
|
||||
h('div', { class: 'tbl' }, h('table', { class: 'narrow' },
|
||||
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
tbody))));
|
||||
}
|
||||
|
||||
async function viewSettings(wrap) {
|
||||
if (!me.isAdmin) {
|
||||
fill(wrap, h('h1', null, 'Settings'), h('div', { class: 'notice' }, 'API tokens cannot change settings. Sign in to the web interface.'));
|
||||
@@ -1236,31 +1372,6 @@
|
||||
const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]);
|
||||
let logLevelFilter = 'all';
|
||||
|
||||
// my account
|
||||
const uname = h('input', { id: 'u', value: me.name, autocomplete: 'username', autocapitalize: 'none' });
|
||||
const cur = h('input', { id: 'pc', type: 'password', autocomplete: 'current-password' });
|
||||
const p1 = h('input', { id: 'p1', type: 'password', autocomplete: 'new-password', placeholder: 'At least 12 characters' });
|
||||
const p2 = h('input', { id: 'p2', type: 'password', autocomplete: 'new-password' });
|
||||
const accErr = h('p', { class: 'err-text', role: 'alert' });
|
||||
const savePw = async (e) => {
|
||||
e.preventDefault();
|
||||
accErr.textContent = '';
|
||||
try {
|
||||
if (p1.value || p2.value || cur.value) {
|
||||
if (p1.value !== p2.value) throw new Error('The new passwords do not match');
|
||||
await api('POST', '/auth/password', { current: cur.value, new: p1.value });
|
||||
toast('Password changed. Other browsers are signed out.');
|
||||
}
|
||||
if (uname.value.trim() !== me.name) {
|
||||
await api('PATCH', '/users/' + me.id, { username: uname.value.trim() });
|
||||
toast('Username saved');
|
||||
}
|
||||
me = await api('GET', '/auth/me');
|
||||
main = null;
|
||||
render();
|
||||
} catch (x) { accErr.textContent = x.message; }
|
||||
};
|
||||
|
||||
// users
|
||||
const userBody = h('tbody');
|
||||
const drawUsers = (users) => userBody.replaceChildren(...users.map((u) => h('tr', null,
|
||||
@@ -1269,7 +1380,9 @@
|
||||
h('td', null, u.lastLogin ? ago(u.lastLogin.at) + ' · ' + u.lastLogin.ip : h('span', { class: 'muted' }, 'Not since restart')),
|
||||
h('td', null, u.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')),
|
||||
h('td', null, fmtDate(u.created)),
|
||||
h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn small', onClick: () => editUser(u) }, 'Edit')))));
|
||||
h('td', { class: 'num' }, u.you
|
||||
? h('a', { class: 'btn small', href: '#/account' }, 'My account')
|
||||
: h('button', { type: 'button', class: 'btn small', onClick: () => editUser(u) }, 'Edit')))));
|
||||
drawUsers(us.users);
|
||||
const reloadUsers = async () => drawUsers((await api('GET', '/users')).users);
|
||||
const pwField = (id, label) => {
|
||||
@@ -1324,21 +1437,20 @@
|
||||
const body = {};
|
||||
if (nm.value.trim() !== u.username) body.username = nm.value.trim();
|
||||
if (note.value.trim() !== u.note) body.note = note.value.trim();
|
||||
if (!u.you && must.box.checked !== u.mustChangePassword) body.mustChangePassword = must.box.checked;
|
||||
if (must.box.checked !== u.mustChangePassword) body.mustChangePassword = must.box.checked;
|
||||
if (Object.keys(body).length) await api('PATCH', '/users/' + u.id, body);
|
||||
close();
|
||||
if (u.you) { me = await api('GET', '/auth/me'); main = null; render(); } else reloadUsers();
|
||||
reloadUsers();
|
||||
} catch (x) { e.textContent = x.message; }
|
||||
} },
|
||||
h('h2', null, 'Edit ' + u.username),
|
||||
h('div', { class: 'grid' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'eu' }, 'Username'), nm),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'en' }, 'Note'), note)),
|
||||
u.you ? h('p', { class: 'hint' }, 'Change your own password under My account.') : [
|
||||
must.el,
|
||||
h('div', { class: 'actions' },
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'),
|
||||
h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…'))],
|
||||
must.el,
|
||||
h('div', { class: 'actions' },
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'),
|
||||
h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…')),
|
||||
e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
|
||||
};
|
||||
@@ -1416,38 +1528,6 @@
|
||||
if (!await confirmDialog({ title: 'Revoke ' + t.name + '?', text: 'Apps using this token are signed out immediately.', ok: 'Revoke', danger: true })) return;
|
||||
try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); }
|
||||
};
|
||||
const newToken = () => {
|
||||
const nm = h('input', { id: 'tn', value: 'iPhone app' });
|
||||
const sc = h('select', { id: 'ts' }, h('option', { value: 'rw' }, 'Full access'), h('option', { value: 'ro' }, 'Read only'));
|
||||
const e = h('p', { class: 'err-text' });
|
||||
const d = dialog((close) => {
|
||||
const body = h('form', { class: 'dlg', onSubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const r = await api('POST', '/tokens', { name: nm.value, scope: sc.value });
|
||||
body.replaceChildren(
|
||||
h('h2', null, 'Scan with the iOS app'),
|
||||
h('div', { class: 'notice' }, 'The token is shown only now. Only a hash is stored on the server.'),
|
||||
h('div', { class: 'qrrow' },
|
||||
h('img', { class: 'qr', src: r.qr, alt: 'Pairing QR code' }),
|
||||
h('div', { class: 'col' },
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => copy(r.pairing) }, 'Copy pairing code'),
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => copy(r.token) }, 'Copy token'))),
|
||||
h('pre', { class: 'code' }, r.token),
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done')));
|
||||
reloadTokens();
|
||||
} catch (x) { e.textContent = x.message; }
|
||||
} },
|
||||
h('h2', null, 'New API token'),
|
||||
h('p', null, 'For the iOS app or scripts. The pairing QR code holds the server address, the token and the certificate fingerprint.'),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'tn' }, 'Name'), nm),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'ts' }, 'Access'), sc),
|
||||
e,
|
||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Create token')));
|
||||
return body;
|
||||
});
|
||||
return d;
|
||||
};
|
||||
|
||||
// logs
|
||||
const logBox = h('pre', { class: 'log', tabindex: '0', 'aria-label': 'Log lines, newest first' });
|
||||
@@ -1519,17 +1599,6 @@
|
||||
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention and backups')),
|
||||
restartBox,
|
||||
|
||||
h('form', { class: 'card', onSubmit: savePw, 'aria-labelledby': 'acc' },
|
||||
h('h2', { id: 'acc' }, 'My account'),
|
||||
h('p', { class: 'lead' }, 'Changing your password signs you out in other browsers. Your app tokens keep working.'),
|
||||
h('div', { class: 'grid' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), uname),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'pc' }, 'Current password'), cur),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'p1' }, 'New password'), p1),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'p2' }, 'Confirm new password'), p2)),
|
||||
accErr,
|
||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn' }, 'Save account'))),
|
||||
|
||||
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
|
||||
@@ -1555,7 +1624,7 @@
|
||||
h('section', { class: 'card', 'aria-labelledby': 'api' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: newToken }, 'Pair iOS app')),
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: () => pairDialog(reloadTokens) }, 'Pair iOS app')),
|
||||
h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
|
||||
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
tbody))),
|
||||
|
||||
Reference in New Issue
Block a user