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:
@@ -221,10 +221,14 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
||||||
p := who(r)
|
p := who(r)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
out := map[string]any{
|
||||||
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||||
"mustChangePassword": p.MustChangePassword, "version": version,
|
"mustChangePassword": p.MustChangePassword, "version": version, "session": p.Session,
|
||||||
})
|
}
|
||||||
|
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||||
|
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// changePassword changes the signed-in user's own password. Their other
|
// changePassword changes the signed-in user's own password. Their other
|
||||||
@@ -270,7 +274,11 @@ func (a *App) changePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
if c, err := r.Cookie(cookieName()); err == nil {
|
if c, err := r.Cookie(cookieName()); err == nil {
|
||||||
a.auth.Logout(c.Value)
|
a.auth.Logout(c.Value)
|
||||||
}
|
}
|
||||||
a.setSessionCookie(w, r, a.auth.NewSession(&updated))
|
info := sessionInfo{Started: time.Now(), IP: remoteIP(r)}
|
||||||
|
if s := who(r).Session; s != nil {
|
||||||
|
info = *s
|
||||||
|
}
|
||||||
|
a.setSessionCookie(w, r, a.auth.NewSession(&updated, info))
|
||||||
a.audit(r, "password changed")
|
a.audit(r, "password changed")
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
@@ -1008,6 +1016,7 @@ type tokenView struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Owner string `json:"owner"` // username
|
Owner string `json:"owner"` // username
|
||||||
|
OwnerID string `json:"ownerId"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
LastUsed *tokenUse `json:"lastUsed"`
|
LastUsed *tokenUse `json:"lastUsed"`
|
||||||
}
|
}
|
||||||
@@ -1016,7 +1025,7 @@ func (a *App) listTokens(w http.ResponseWriter, r *http.Request) {
|
|||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
out := []tokenView{}
|
out := []tokenView{}
|
||||||
for _, t := range cfg.APITokens {
|
for _, t := range cfg.APITokens {
|
||||||
out = append(out, tokenView{t.ID, t.Name, t.Scope, a.username(cfg, t.UserID), t.Created, a.auth.TokenUse(t.ID)})
|
out = append(out, tokenView{t.ID, t.Name, t.Scope, a.username(cfg, t.UserID), t.UserID, t.Created, a.auth.TokenUse(t.ID)})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
|
writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,16 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
|||||||
.side a.nav:hover { background: #222328; color: #fff; }
|
.side a.nav:hover { background: #222328; color: #fff; }
|
||||||
.side a.nav.on { background: #2a2b31; color: #fff; }
|
.side a.nav.on { background: #2a2b31; color: #fff; }
|
||||||
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
|
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
|
||||||
.side .foot { margin-top: auto; padding: 16px 12px 0; font-size: 12px; color: #8d8e93; }
|
.side .foot { margin-top: auto; padding-top: 16px; border-top: 1px solid #2c2d32; display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #8d8e93; }
|
||||||
.side .foot button { background: none; border: 0; padding: 0; font: inherit; color: #c9c9c3; text-decoration: underline; cursor: pointer; }
|
.side .foot button { background: none; border: 0; padding: 0; font: inherit; color: #c9c9c3; text-decoration: underline; cursor: pointer; }
|
||||||
|
.side .acct { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 12px; border-radius: 8px; color: #c9c9c3; text-decoration: none; }
|
||||||
|
.side .acct:hover { background: #222328; color: #fff; }
|
||||||
|
.side .acct.on { background: #2a2b31; color: #fff; }
|
||||||
|
.side .acct > span:last-child { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
|
||||||
|
.side .acct strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.side .acct span span { color: #a9aaa5; }
|
||||||
|
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
|
||||||
|
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
|
||||||
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
||||||
.wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
.wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||||
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
|
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
|
||||||
|
|||||||
@@ -463,6 +463,41 @@
|
|||||||
h('div', { class: 'xaxis' }, h('span', null, '24 h ago'), h('span', null, '12 h ago'), h('span', null, 'now')));
|
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 ----------
|
// ---------- shell, router ----------
|
||||||
|
|
||||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
||||||
@@ -478,9 +513,12 @@
|
|||||||
srvBox,
|
srvBox,
|
||||||
NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))),
|
NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))),
|
||||||
h('div', { class: 'foot' },
|
h('div', { class: 'foot' },
|
||||||
'Signed in as ', me.name, ' · ',
|
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
|
||||||
h('button', { type: 'button', onClick: logout }, 'Sign out'),
|
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
|
||||||
h('br'), 'v' + me.version));
|
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' });
|
main = h('main', { class: 'main', id: 'main' });
|
||||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||||
refreshSide();
|
refreshSide();
|
||||||
@@ -516,6 +554,7 @@
|
|||||||
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
||||||
[/^#\/server$/, '#/server', viewServer],
|
[/^#\/server$/, '#/server', viewServer],
|
||||||
[/^#\/settings$/, '#/settings', viewSettings],
|
[/^#\/settings$/, '#/settings', viewSettings],
|
||||||
|
[/^#\/account$/, '#/account', viewAccount],
|
||||||
];
|
];
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
@@ -1228,6 +1267,103 @@
|
|||||||
|
|
||||||
// ---------- settings ----------
|
// ---------- 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) {
|
async function viewSettings(wrap) {
|
||||||
if (!me.isAdmin) {
|
if (!me.isAdmin) {
|
||||||
fill(wrap, h('h1', null, 'Settings'), h('div', { class: 'notice' }, 'API tokens cannot change settings. Sign in to the web interface.'));
|
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')]);
|
const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]);
|
||||||
let logLevelFilter = 'all';
|
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
|
// users
|
||||||
const userBody = h('tbody');
|
const userBody = h('tbody');
|
||||||
const drawUsers = (users) => userBody.replaceChildren(...users.map((u) => h('tr', null,
|
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.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, u.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')),
|
||||||
h('td', null, fmtDate(u.created)),
|
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);
|
drawUsers(us.users);
|
||||||
const reloadUsers = async () => drawUsers((await api('GET', '/users')).users);
|
const reloadUsers = async () => drawUsers((await api('GET', '/users')).users);
|
||||||
const pwField = (id, label) => {
|
const pwField = (id, label) => {
|
||||||
@@ -1324,21 +1437,20 @@
|
|||||||
const body = {};
|
const body = {};
|
||||||
if (nm.value.trim() !== u.username) body.username = nm.value.trim();
|
if (nm.value.trim() !== u.username) body.username = nm.value.trim();
|
||||||
if (note.value.trim() !== u.note) body.note = note.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);
|
if (Object.keys(body).length) await api('PATCH', '/users/' + u.id, body);
|
||||||
close();
|
close();
|
||||||
if (u.you) { me = await api('GET', '/auth/me'); main = null; render(); } else reloadUsers();
|
reloadUsers();
|
||||||
} catch (x) { e.textContent = x.message; }
|
} catch (x) { e.textContent = x.message; }
|
||||||
} },
|
} },
|
||||||
h('h2', null, 'Edit ' + u.username),
|
h('h2', null, 'Edit ' + u.username),
|
||||||
h('div', { class: 'grid' },
|
h('div', { class: 'grid' },
|
||||||
h('div', { class: 'field' }, h('label', { htmlFor: 'eu' }, 'Username'), nm),
|
h('div', { class: 'field' }, h('label', { htmlFor: 'eu' }, 'Username'), nm),
|
||||||
h('div', { class: 'field' }, h('label', { htmlFor: 'en' }, 'Note'), note)),
|
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,
|
||||||
must.el,
|
h('div', { class: 'actions' },
|
||||||
h('div', { class: 'actions' },
|
h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'),
|
||||||
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…')),
|
||||||
h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…'))],
|
|
||||||
e,
|
e,
|
||||||
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
|
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;
|
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); }
|
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
|
// logs
|
||||||
const logBox = h('pre', { class: 'log', tabindex: '0', 'aria-label': 'Log lines, newest first' });
|
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')),
|
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,
|
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('section', { class: 'card flush', 'aria-labelledby': 'usr' },
|
||||||
h('div', { class: 'cardhead' },
|
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.')),
|
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('section', { class: 'card', 'aria-labelledby': 'api' },
|
||||||
h('div', { class: 'cardhead' },
|
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('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('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')))),
|
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))),
|
tbody))),
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ type principal struct {
|
|||||||
RemoteIP string
|
RemoteIP string
|
||||||
// MustChangePassword blocks everything but changing the password.
|
// MustChangePassword blocks everything but changing the password.
|
||||||
MustChangePassword bool
|
MustChangePassword bool
|
||||||
|
Session *sessionInfo // nil for API tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionInfo is when and from where a browser session started.
|
||||||
|
type sessionInfo struct {
|
||||||
|
Started time.Time `json:"started"`
|
||||||
|
IP string `json:"ip"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type session struct {
|
type session struct {
|
||||||
@@ -110,6 +117,7 @@ type session struct {
|
|||||||
// password ends every session started with the old one.
|
// password ends every session started with the old one.
|
||||||
stamp string
|
stamp string
|
||||||
expires time.Time
|
expires time.Time
|
||||||
|
info sessionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenUse struct {
|
type tokenUse struct {
|
||||||
@@ -185,20 +193,21 @@ func (a *Auth) Login(user, pw, ip string) (string, error) {
|
|||||||
}
|
}
|
||||||
delete(a.fails, ip)
|
delete(a.fails, ip)
|
||||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||||
return a.newSessionLocked(cfg, u), nil
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSession signs a user in again, e.g. after they changed their password.
|
// NewSession replaces a session after the user changed their password; it
|
||||||
func (a *Auth) NewSession(u *User) string {
|
// keeps when and from where the old one started.
|
||||||
|
func (a *Auth) NewSession(u *User, info sessionInfo) string {
|
||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
return a.newSessionLocked(cfg, u)
|
return a.newSessionLocked(cfg, u, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) newSessionLocked(cfg *Config, u *User) string {
|
func (a *Auth) newSessionLocked(cfg *Config, u *User, info sessionInfo) string {
|
||||||
id := randomString(32)
|
id := randomString(32)
|
||||||
a.sessions[id] = &session{userID: u.ID, stamp: u.PasswordHash, expires: time.Now().Add(time.Duration(cfg.Web.SessionHours) * time.Hour)}
|
a.sessions[id] = &session{userID: u.ID, stamp: u.PasswordHash, expires: time.Now().Add(time.Duration(cfg.Web.SessionHours) * time.Hour), info: info}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +289,8 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
|
|||||||
delete(a.sessions, c.Value)
|
delete(a.sessions, c.Value)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword}, true
|
info := s.info
|
||||||
|
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword, Session: &info}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) TokenUse(id string) *tokenUse {
|
func (a *Auth) TokenUse(id string) *tokenUse {
|
||||||
|
|||||||
@@ -821,6 +821,9 @@ func TestUsers(t *testing.T) {
|
|||||||
admin("POST", "/auth/login", map[string]string{"username": "ADMIN", "password": "a long test password"}, 200) // any letter case
|
admin("POST", "/auth/login", map[string]string{"username": "ADMIN", "password": "a long test password"}, 200) // any letter case
|
||||||
me := admin("GET", "/auth/me", nil, 200)
|
me := admin("GET", "/auth/me", nil, 200)
|
||||||
myID := me["id"].(string)
|
myID := me["id"].(string)
|
||||||
|
if sess, _ := me["session"].(map[string]any); sess == nil || sess["ip"] != "127.0.0.1" || me["username"] != "admin" || me["created"] == nil {
|
||||||
|
t.Fatalf("me lacks the session or profile: %v", me)
|
||||||
|
}
|
||||||
|
|
||||||
// A new user with a temporary password can only change it.
|
// A new user with a temporary password can only change it.
|
||||||
eve := admin("POST", "/users", map[string]any{"username": "eve", "password": "temporary password 1"}, 201)["user"].(map[string]any)
|
eve := admin("POST", "/users", map[string]any{"username": "eve", "password": "temporary password 1"}, 201)["user"].(map[string]any)
|
||||||
@@ -840,6 +843,9 @@ func TestUsers(t *testing.T) {
|
|||||||
e("POST", "/auth/password", map[string]string{"current": "temporary password 1", "new": "temporary password 1"}, 400)
|
e("POST", "/auth/password", map[string]string{"current": "temporary password 1", "new": "temporary password 1"}, 400)
|
||||||
e("POST", "/auth/password", map[string]string{"current": "temporary password 1", "new": "eve's own password"}, 200)
|
e("POST", "/auth/password", map[string]string{"current": "temporary password 1", "new": "eve's own password"}, 200)
|
||||||
e("GET", "/peers", nil, 200) // the session continues after the change
|
e("GET", "/peers", nil, 200) // the session continues after the change
|
||||||
|
if sess, _ := e("GET", "/auth/me", nil, 200)["session"].(map[string]any); sess == nil || sess["started"] == nil {
|
||||||
|
t.Fatal("session start lost after the password change")
|
||||||
|
}
|
||||||
|
|
||||||
// A user created without the flag can work at once.
|
// A user created without the flag can work at once.
|
||||||
sam := admin("POST", "/users", map[string]any{"username": "sam", "password": "sam's password 123", "mustChangePassword": false}, 201)["user"].(map[string]any)
|
sam := admin("POST", "/users", map[string]any{"username": "sam", "password": "sam's password 123", "mustChangePassword": false}, 201)["user"].(map[string]any)
|
||||||
|
|||||||
Reference in New Issue
Block a user