From 19008f8a33f04adeea1fc67b340cee6379d51072 Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sun, 4 Oct 2026 16:07:21 +0300 Subject: [PATCH] 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. --- api.go | 19 +++-- app.css | 10 ++- app.js | 229 +++++++++++++++++++++++++++++++++------------------ auth.go | 24 ++++-- main_test.go | 6 ++ 5 files changed, 195 insertions(+), 93 deletions(-) diff --git a/api.go b/api.go index 40f470e..624536e 100644 --- a/api.go +++ b/api.go @@ -221,10 +221,14 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) { func (a *App) me(w http.ResponseWriter, r *http.Request) { 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, - "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 @@ -270,7 +274,11 @@ func (a *App) changePassword(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(cookieName()); err == nil { 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") writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } @@ -1008,6 +1016,7 @@ type tokenView struct { Name string `json:"name"` Scope string `json:"scope"` Owner string `json:"owner"` // username + OwnerID string `json:"ownerId"` Created time.Time `json:"created"` LastUsed *tokenUse `json:"lastUsed"` } @@ -1016,7 +1025,7 @@ func (a *App) listTokens(w http.ResponseWriter, r *http.Request) { cfg := a.store.Get() out := []tokenView{} 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}) } diff --git a/app.css b/app.css index b9b81b3..5440b09 100644 --- a/app.css +++ b/app.css @@ -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.on { background: #2a2b31; color: #fff; } .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 .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; } .wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; } @media (max-width: 640px) { .main { padding: 20px 16px 40px; } } diff --git a/app.js b/app.js index d447f65..ba5a6ab 100644 --- a/app.js +++ b/app.js @@ -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))), diff --git a/auth.go b/auth.go index 9690526..a6e17dc 100644 --- a/auth.go +++ b/auth.go @@ -102,6 +102,13 @@ type principal struct { RemoteIP string // MustChangePassword blocks everything but changing the password. 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 { @@ -110,6 +117,7 @@ type session struct { // password ends every session started with the old one. stamp string expires time.Time + info sessionInfo } type tokenUse struct { @@ -185,20 +193,21 @@ func (a *Auth) Login(user, pw, ip string) (string, error) { } delete(a.fails, 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. -func (a *Auth) NewSession(u *User) string { +// NewSession replaces a session after the user changed their password; it +// keeps when and from where the old one started. +func (a *Auth) NewSession(u *User, info sessionInfo) string { cfg := a.store.Get() a.mu.Lock() 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) - 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 } @@ -280,7 +289,8 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) { delete(a.sessions, c.Value) 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 { diff --git a/main_test.go b/main_test.go index d452412..e96a920 100644 --- a/main_test.go +++ b/main_test.go @@ -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 me := admin("GET", "/auth/me", nil, 200) 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. 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": "eve's own password"}, 200) 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. sam := admin("POST", "/users", map[string]any{"username": "sam", "password": "sam's password 123", "mustChangePassword": false}, 201)["user"].(map[string]any)