Accounts people can create, and photos on a server with no bucket

A tenant had exactly the users somebody had created with a command on the
server. That is not a missing screen: a shop with an owner and four staff
either shared one password or raised a ticket per person, and a phone app
for the shop floor could not exist while there was one account to sign in
as.

Registration is by invitation, never open signup - the same line already
drawn around creating a company. The code carries the address and the role
and the request carries only a password, so a code that gets forwarded
cannot become somebody else's account, and a staff invitation cannot be
redeemed as an owner. Single use lives in the UPDATE and the account is
created in the same transaction.

Deactivating a member revokes their sessions in that transaction too. An
access token lives twelve hours, so without it "remove their access"
removed it sometime tomorrow. The session list and revoke that go with it
are the benefit of opaque tokens the product had been paying for and never
collecting: nothing could say what was signed in, let alone stop one.

Face images now work on a deployment with no object storage, which was
every local install and every self-hosted site - the arrivals feed said
"not storing customer photos" for every customer forever, on the screen
whose whole job is to show a face. Bounded to one row per visitor, so it
grows with the customer base and not with footfall; the bucket stays
primary wherever one exists.

Image.auth says whether a URL needs the session, because a browser img
cannot load one that does, a mobile image view can, and a webview can do
neither - the desktop client resolves those to a data URI in Go.

Found by running it, not by tests:

  * UPDATE ... RETURNING gives the value AFTER the update, so the prune
    read back empty keys, deleted nothing, and the table grew with
    footfall exactly as if it were not there. The fake agreed with either
    version; only the live Postgres test caught it.
  * Trusting only the auth flag broke every shop card, because Sites.jsx
    rebuilt a partial snapshot object and dropped it. A relative URL is
    now sufficient on its own.
  * ago() renders a future time as "just now", so a code valid for a week
    read "expires just now".

Verified live against real Postgres: invite, preview, escalation refused,
register into a session, replay 404, staff forbidden, device revoked and
401 at once, last owner refused, and a 92,405-byte camera JPEG stored,
served to its owner, 401 with no session, 404 to another tenant, and
rendered in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-05 11:45:42 +05:30
parent ffae7e45d5
commit 3f9fb33b24
38 changed files with 4125 additions and 106 deletions

214
web/src/views/Team.jsx Normal file
View File

@@ -0,0 +1,214 @@
import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import { ago, until, Loading, Problem } from './Sites.jsx'
// The people who work here, and how somebody new gets an account.
//
// Registration is by invitation, never open signup — the same line the platform
// draws around creating a company. What was missing was not openness: it was
// that a shop could not add a SECOND person at all without somebody running a
// command on the server, so five members of staff shared one password and a
// phone app for the shop floor could not exist.
//
// A manager mints a code and hands it over; the holder chooses their own
// password. The code carries the address and the role, so passing it on cannot
// turn a staff invitation into an owner account for whoever received it.
export default function Team({ user }) {
const team = usePolled(() => api.team(), 0, [])
const invites = usePolled(() => api.invitations(), 0, [])
const [inviting, setInviting] = useState(false)
const [minted, setMinted] = useState(null)
const [busy, setBusy] = useState('')
const [error, setError] = useState('')
const canManage = user.role === 'owner' || user.role === 'manager'
const members = team.data || []
const pending = invites.data || []
const change = async (id, changes) => {
setBusy(id); setError('')
try {
await api.updateMember(id, changes)
team.reload()
} catch (err) {
setError(err.message)
} finally {
setBusy('')
}
}
return (
<>
<header className="head">
<h1>Team</h1>
{canManage && (
<button className="primary" onClick={() => { setMinted(null); setInviting(true) }}>
Invite someone
</button>
)}
</header>
{minted && <InviteCode invite={minted} onDismiss={() => setMinted(null)} />}
{error && <p className="error" role="alert">{error}</p>}
{team.loading && !team.data ? <Loading /> :
team.error ? <Problem error={team.error} /> : (
<div className="tablewrap">
<table className="rows">
<thead>
<tr><th>Name</th><th>Email</th><th>Role</th><th>Last signed in</th>
{canManage && <th />}</tr>
</thead>
<tbody>
{members.map(m => (
<tr key={m.id} className={m.active ? '' : 'inactive'}>
<td><strong>{m.full_name || '—'}</strong>
{!m.active && <span className="pill muted">No access</span>}</td>
<td><code>{m.email}</code></td>
<td>{canManage && m.id !== user.id ? (
<select value={m.role} disabled={busy === m.id}
onChange={e => change(m.id, { role: e.target.value })}>
{/* 'admin' is absent: a platform administrator is
defined by having no company, so the role could
never work on a row that has one. */}
<option value="staff">Staff</option>
<option value="manager">Manager</option>
{user.role === 'owner' && <option value="owner">Owner</option>}
</select>
) : <span className="role">{m.role}</span>}</td>
<td className="sub">{m.last_login_at ? ago(m.last_login_at) : 'Never'}</td>
{canManage && (
<td className="right">
{m.id === user.id ? null : m.active ? (
<button className="ghost danger" disabled={busy === m.id}
onClick={() => change(m.id, { active: false })}>
Remove access
</button>
) : (
<button className="ghost" disabled={busy === m.id}
onClick={() => change(m.id, { active: true })}>
Restore
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{canManage && pending.length > 0 && (
<section className="pending">
<h2>Waiting to join</h2>
<ul className="invites">
{pending.map(i => (
<li key={i.id} className="card invite">
<div>
<strong>{i.email}</strong>
<span className="sub">
invited as {i.role}
{i.invited_by ? ` by ${i.invited_by}` : ''} · expires {until(i.expires_at)}
</span>
</div>
<button className="ghost danger" onClick={async () => {
await api.revokeInvitation(i.id); invites.reload()
}}>Withdraw</button>
</li>
))}
</ul>
</section>
)}
{inviting && (
<InviteForm
canMintOwner={user.role === 'owner'}
onClose={() => setInviting(false)}
onDone={(inv) => { setInviting(false); setMinted(inv); invites.reload() }}
/>
)}
</>
)
}
function InviteForm({ canMintOwner, onClose, onDone }) {
const [form, setForm] = useState({ email: '', full_name: '', role: 'staff' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError('')
try {
onDone(await api.invite(form))
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="overlay" onClick={onClose}>
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
<header className="drawer-head">
<h2>Invite someone</h2>
<button className="ghost" onClick={onClose}>Close</button>
</header>
<form className="drawer-body" onSubmit={submit}>
<label>Their email
<input type="email" value={form.email} onChange={set('email')}
required autoFocus autoComplete="off" name="invitee" />
<span className="hint">
This is the address they will sign in with, and it is fixed by the
invitation — passing the code on cannot make it somebody else’s
account.
</span>
</label>
<label>Their name
<input value={form.full_name} onChange={set('full_name')}
autoComplete="off" name="invitee-name" />
</label>
<label>Role
<select value={form.role} onChange={set('role')}>
<option value="staff">Staff — see customers and shops</option>
<option value="manager">Manager — also set up cameras and invite people</option>
{canMintOwner && <option value="owner">Owner — full control</option>}
</select>
</label>
{error && <p className="error" role="alert">{error}</p>}
<button className="primary" disabled={busy}>
{busy ? 'Creating…' : 'Create invitation'}
</button>
</form>
</aside>
</div>
)
}
// Shown once, and it says so. Only a hash is stored, so this cannot be read
// back later — the same rule every other secret in this product follows, and
// the reason is the same: a code support can look up is a code anybody with
// support access can redeem.
function InviteCode({ invite, onDismiss }) {
return (
<div className="banner ok credentials">
<div>
<b>Invitation for {invite.email}.</b> Give them this code. It is shown
once, works once, and cannot be recovered.
<dl className="creds">
<div><dt>Code</dt><dd><code className="big">{invite.code}</code></dd></div>
<div><dt>Role</dt><dd>{invite.role}</dd></div>
</dl>
<p className="sub">
They open the app, choose “I have an invitation code”, and pick their
own password. Nobody else ever sees it.
</p>
</div>
<button className="ghost" onClick={onDismiss}>Done</button>
</div>
)
}