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

View File

@@ -6,6 +6,7 @@ import Live from './views/Live.jsx'
import CamerasView from './views/Cameras.jsx'
import Assistant from './views/Assistant.jsx'
import Clients from './views/Clients.jsx'
import Team from './views/Team.jsx'
// A platform admin has no client of their own, so the tenant screens have
// nothing to show them. Rather than render empty pages, they get the one screen
@@ -22,6 +23,7 @@ const TENANT_VIEWS = [
{ id: 'sites', label: 'Shops', View: Sites },
{ id: 'live', label: 'Live', View: Live },
{ id: 'cameras', label: 'Cameras', View: CamerasView },
{ id: 'team', label: 'Team', View: Team },
]
const ADMIN_VIEWS = [
{ id: 'clients', label: 'Companies', View: Clients },

View File

@@ -122,6 +122,23 @@ async function fetchImage(path, retry = true) {
parsed?.message || `That picture could not be loaded (${res.status}).`)
}
// A label for the session list, so somebody can tell which device to sign out.
// Deliberately coarse and never an identifier: a fingerprint here would be a
// tracking signal we have no reason to hold, and the question this answers is
// only "which of these is the one in my hand".
function deviceName() {
const ua = navigator.userAgent || ''
const os = /Windows/.test(ua) ? 'Windows'
: /Mac OS X|Macintosh/.test(ua) ? 'Mac'
: /Android/.test(ua) ? 'Android'
: /iPhone|iPad/.test(ua) ? 'iOS' : 'Unknown'
const browser = /Edg\//.test(ua) ? 'Edge'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: /Firefox\//.test(ua) ? 'Firefox' : 'browser'
return `${browser} on ${os}`
}
const qs = (params) => {
const p = new URLSearchParams()
for (const [k, v] of Object.entries(params || {})) {
@@ -136,7 +153,7 @@ export const api = {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, device: deviceName() }),
})
const body = await res.json().catch(() => null)
if (!res.ok) {
@@ -147,6 +164,39 @@ export const api = {
return body.user
},
// What a code says it is for, before anybody is asked to choose a password.
// Unauthenticated by necessity: the holder has no account yet.
async previewInvitation(code) {
const res = await fetch('/api/auth/invitation' + qs({ code }))
const body = await res.json().catch(() => null)
if (!res.ok) {
throw new ApiError(res.status, body?.error || '',
body?.message || 'That invitation code is not valid.')
}
return body
},
// Redeem an invitation. Returns a signed-in session, not just an account:
// sending somebody who has just chosen a password to a sign-in form to type
// it again is the sort of thing that gets blamed on the password.
//
// The address and the role are NOT sent - they come from the invitation, and
// the server refuses a body that names either.
async register({ code, full_name, password }) {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, full_name, password, device: deviceName() }),
})
const body = await res.json().catch(() => null)
if (!res.ok) {
throw new ApiError(res.status, body?.error || '',
body?.message || 'Could not create the account.')
}
setTokens(body.access_token, body.refresh_token)
return body.user
},
async logout() {
try { await send('POST', '/api/auth/logout') } catch { /* already gone */ }
clearTokens()
@@ -196,6 +246,25 @@ export const api = {
clients: () => send('GET', '/api/admin/clients'),
createClient: (input) => send('POST', '/api/admin/clients', input),
// The people who work here.
team: () => send('GET', '/api/team'),
updateMember: (id, changes) =>
send('PATCH', `/api/team/${encodeURIComponent(id)}`, changes),
invitations: () => send('GET', '/api/team/invitations'),
// The code comes back in full exactly once - only a hash is stored - so
// whatever calls this has to show it there and then and must not expect to
// read it back later. Same contract as enrolmentCode above.
invite: (input) => send('POST', '/api/team/invitations', input),
revokeInvitation: (id) =>
send('DELETE', `/api/team/invitations/${encodeURIComponent(id)}`),
// Devices this account is signed in on. The point of holding sessions in a
// table rather than issuing JWTs is that signing one out actually works.
sessions: () => send('GET', '/api/auth/sessions'),
revokeSession: (id) =>
send('DELETE', `/api/auth/sessions/${encodeURIComponent(id)}`),
signOutOthers: () => send('POST', '/api/auth/sessions/revoke-others'),
}
// The live stream, read with fetch rather than EventSource.

View File

@@ -182,7 +182,12 @@ button.ghost:hover { border-color: var(--muted); color: var(--ink); }
.arrivals { list-style: none; padding: 0; display: grid; gap: 8px; }
.card.arrival { display: flex; align-items: center; gap: 14px; padding: 11px 14px; }
.face { width: 46px; height: 46px; border-radius: 50%; flex: none;
object-fit: cover; background: var(--surface-2); }
overflow: hidden; object-fit: cover; background: var(--surface-2); }
/* .face is a <span> wrapping the picture rather than the <img> itself, because
a face served from this server's own database has to be fetched with the
session before it can be shown. The image inside still has to fill the
circle, and the wrapper clips it. */
.face > img { width: 100%; height: 100%; object-fit: cover; display: block; }
.face.initials { display: grid; place-items: center; color: var(--muted);
font-size: 15px; font-weight: 600; letter-spacing: .02em; }
.who-col { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
@@ -536,3 +541,30 @@ button.ghost.danger:hover { border-color: var(--bad); }
font-size: 13px; color: rgba(255, 255, 255, .78);
background: rgba(0, 0, 0, .35);
}
/* ---------------------------------------------------------------- team --- */
.rows tr.inactive td { opacity: .55; }
.rows .role { text-transform: capitalize; }
.rows td.right { text-align: right; }
.pill.muted { margin-left: 8px; font-size: 11px; padding: 1px 7px; border-radius: 999px;
background: var(--surface-2); color: var(--muted); vertical-align: middle; }
.pending { margin-top: 26px; }
.pending h2 { font-size: 14px; font-weight: 600; color: var(--muted); margin: 0 0 10px; }
.invites { list-style: none; padding: 0; display: grid; gap: 8px; }
.card.invite { display: flex; align-items: center; justify-content: space-between;
gap: 14px; padding: 11px 14px; }
.card.invite .sub { display: block; }
/* The code is read aloud and typed in, so it is set wide and monospaced.
Grouped in sixes by the server for the same reason. */
.creds code.big { font-size: 16px; letter-spacing: .06em; }
/* A button that reads as a link. Used where the action is a change of screen
rather than a submission, so it must not look like the primary button next
to it. */
.linkish { background: none; border: 0; padding: 0; font: inherit;
color: var(--accent); cursor: pointer; text-decoration: underline;
text-underline-offset: 2px; }
.linkish:hover { opacity: .8; }
/* The code is read off a screen or a phone call, so it is set wide. */
.codefield { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: .04em; text-transform: uppercase; }

View File

@@ -125,7 +125,7 @@ function CameraCard({ cam, canEdit, onEdit, onWatch }) {
onKeyDown={e => canEdit && e.key === 'Enter' && onEdit()}>
<div className="shot">
{cam.snapshot?.available
? <Shot url={cam.snapshot.url} alt={`View from ${cam.label}`} />
? <Shot image={cam.snapshot} alt={`View from ${cam.label}`} />
: <div className="noshot">
<span className="lens" aria-hidden="true" />
{cam.snapshot?.reason || 'No picture yet.'}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { api, streamArrivals } from '../api.js'
import { ago, Loading, Problem } from './Sites.jsx'
import Shot from './Shot.jsx'
// Who just walked in, pushed as it happens.
//
@@ -103,9 +104,16 @@ function Arrival({ a }) {
// Photos are off unless a shop turns them on, so "no photo" is the ordinary
// case. Initials, never an error state — a screen full of red for a system
// working exactly as configured teaches people to ignore it.
//
// The picture goes through Shot rather than a bare <img> because a face can
// now come from either of two places: a presigned link to object storage, or
// this server's own database on a deployment with no bucket. The second cannot
// be loaded by an <img> at all — it needs the session — so a plain src here
// showed a broken image on exactly the deployments that had just started
// storing photos.
function Face({ image, name }) {
if (image?.available) {
return <img className="face" src={image.url} alt="" loading="lazy" />
return <span className="face"><Shot image={image} alt="" /></span>
}
return (
<span className="face initials" title={image?.reason || ''} aria-hidden="true">

View File

@@ -1,7 +1,21 @@
import { useState } from 'react'
import { api } from '../api.js'
// Sign in, or join with an invitation.
//
// Both live on this screen because they answer the same question — "let me in"
// — and the person arriving with a code has no account yet, so they cannot be
// asked to sign in first. That is the same reason the endpoint behind it is
// unauthenticated, and the same reason a shop PC claims itself before anybody
// signs in on it.
export default function Login({ onSignedIn }) {
const [joining, setJoining] = useState(false)
return joining
? <Join onSignedIn={onSignedIn} onCancel={() => setJoining(false)} />
: <SignIn onSignedIn={onSignedIn} onJoin={() => setJoining(true)} />
}
function SignIn({ onSignedIn, onJoin }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
@@ -51,7 +65,122 @@ export default function Login({ onSignedIn }) {
{busy ? 'Signing in…' : 'Sign in'}
</button>
<p className="foot">
Accounts are created by Loyaly. Ask your account manager if you need one.
Been invited? <button type="button" className="linkish" onClick={onJoin}>
Use your invitation code
</button>
</p>
</form>
</div>
)
}
// Redeeming an invitation.
//
// Two steps deliberately. The code is checked FIRST, so somebody who has
// mistyped it finds out before choosing a password — and so the screen can say
// which company they are joining, which is the only thing that makes "is this
// the right code" answerable by the person holding it.
function Join({ onSignedIn, onCancel }) {
const [code, setCode] = useState('')
const [invite, setInvite] = useState(null)
const [fullName, setFullName] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const check = async (e) => {
e.preventDefault()
setBusy(true); setError('')
try {
const prev = await api.previewInvitation(code.trim())
setInvite(prev)
setFullName(prev.full_name || '')
} catch (err) {
// Unknown, expired, spent and withdrawn are one message from the server.
// The difference only helps somebody guessing codes, and the next step is
// the same in all four cases: ask for a new one.
setError(err.message)
} finally {
setBusy(false)
}
}
const join = async (e) => {
e.preventDefault()
if (password !== confirm) {
setError('Those two passwords are not the same.')
return
}
setBusy(true); setError('')
try {
// The address and the role are not sent. They belong to the invitation.
const user = await api.register({ code: code.trim(), full_name: fullName, password })
onSignedIn(user)
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="signin">
<form className="card" onSubmit={invite ? join : check}>
<span className="mark big" aria-hidden="true" />
<h1>{invite ? `Join ${invite.client_name}` : 'Behavision'}</h1>
{!invite ? (
<>
<p className="sub">Enter the invitation code you were given.</p>
<label>
Invitation code
<input
value={code} onChange={e => setCode(e.target.value)}
autoFocus required autoComplete="off" spellCheck="false"
placeholder="ABCDEF-123456-GHIJKL-789012" className="codefield"
/>
<span className="hint">Dashes and capitals do not matter.</span>
</label>
{error && <p className="error" role="alert">{error}</p>}
<button className="primary" disabled={busy || !code.trim()}>
{busy ? 'Checking…' : 'Continue'}
</button>
</>
) : (
<>
<p className="sub">
You are joining as <b>{invite.role}</b>, signing in with{' '}
<code>{invite.email}</code>. Choose a password only you know.
</p>
<label>
Your name
<input value={fullName} onChange={e => setFullName(e.target.value)}
autoFocus autoComplete="name" />
</label>
<label>
Password
<input type="password" value={password} required minLength={8}
autoComplete="new-password"
onChange={e => setPassword(e.target.value)} />
<span className="hint">At least 8 characters. Longer is the only thing that helps.</span>
</label>
<label>
Password again
<input type="password" value={confirm} required
autoComplete="new-password"
onChange={e => setConfirm(e.target.value)} />
</label>
{error && <p className="error" role="alert">{error}</p>}
<button className="primary" disabled={busy || !password || !confirm}>
{busy ? 'Creating your account…' : 'Create account and sign in'}
</button>
</>
)}
<p className="foot">
<button type="button" className="linkish" onClick={onCancel}>
Back to sign in
</button>
</p>
</form>
</div>

View File

@@ -1,24 +1,45 @@
import { useAuthedImage } from '../hooks.js'
// One camera picture, however this deployment stores them.
// One picture from the API, however this deployment stores them.
//
// Two shapes arrive here and they need different handling, which is exactly
// why it is one component rather than an <img> repeated on each screen:
//
// * An ABSOLUTE url is a presigned link to object storage. It carries its
// own signature, so a plain <img src> loads it.
// * A RELATIVE url is served by this server from its own database, for a
// deployment with no bucket. An <img> cannot send an Authorization header,
// so it has to be fetched with the session and handed over as an object
// URL. Minting an unauthenticated link instead would put a photograph of
// somebody's shop floor behind no session at all, which is the thing this
// path exists to avoid.
export default function Shot({ url, alt }) {
const local = typeof url === 'string' && url.startsWith('/')
// * A presigned link to object storage carries its own signature, so a plain
// <img src> loads it.
// * A picture this server holds itself - for a deployment with no bucket -
// is served from an endpoint that requires the session. An <img> cannot
// send an Authorization header, so it has to be fetched and handed over as
// an object URL. Minting an unauthenticated link instead would put a
// photograph of somebody's shop floor, or of a customer, behind no session
// at all, which is the thing that path exists to avoid.
//
// Which one it is comes from the API's own `auth` flag, not from the shape of
// the URL. Guessing by whether it starts with "/" is right today and stops
// being right the first time object storage is served from this same host -
// and the failure then is a photograph that silently will not load.
export default function Shot({ image, url, alt }) {
// `image` is the whole object from the API; `url` is the older call shape,
// kept working so a screen that has not been updated still renders. The
// fallback heuristic applies only when nothing told us.
const src0 = image ? image.url : url
// Either signal is enough, and that is not belt-and-braces. A RELATIVE url is
// served by this server and always needs the session - there is no such thing
// as a public one - so it is sufficient on its own, and a caller that rebuilds
// an image object and loses `auth` cannot turn a working picture into a broken
// one. (It did exactly that once: Sites.jsx returned `{url, at}` from its
// snapshot picker, the flag went missing, and every shop card showed a broken
// image.) The FLAG is what adds the case the URL cannot express: an absolute
// link that still needs a bearer, which happens the first time object storage
// is served from this same host.
const needsAuth =
(image && !!image.auth) ||
(typeof src0 === 'string' && src0.startsWith('/'))
// Hooks cannot be called conditionally, so this always runs and simply has
// nothing to do when the URL is already usable.
const fetched = useAuthedImage(local ? url : null)
const src = local ? fetched : url
const fetched = useAuthedImage(needsAuth ? src0 : null)
const src = needsAuth ? fetched : src0
if (!src) return null
return <img src={src} alt={alt} loading="lazy" />
}

View File

@@ -135,7 +135,7 @@ function SiteCard({ site, cams, verdict, onCheck }) {
tabIndex={0} onKeyDown={e => e.key === 'Enter' && onCheck()}>
<div className="shot">
{view.url
? <Shot url={view.url} alt={`View inside ${site.name}`} />
? <Shot image={view} alt={`View inside ${site.name}`} />
: <div className="noshot">
<ShopMark />
{view.reason && <span>{view.reason}</span>}
@@ -205,7 +205,11 @@ function bestView(cams) {
if (!c.snapshot?.available || !c.snapshot.url) continue
if (!best || (c.snapshot_at || '') > (best.snapshot_at || '')) best = c
}
if (best) return { url: best.snapshot.url, at: best.snapshot_at }
// The WHOLE snapshot object, not just its url. It carries `auth`, which says
// whether the picture has to be fetched with the session or can be handed
// straight to an <img> - and rebuilding a partial copy here is how that flag
// gets silently dropped on one screen and not another.
if (best) return { ...best.snapshot, at: best.snapshot_at }
const reason = cams.map(c => c.snapshot?.reason).find(Boolean)
return { reason: reason || 'No picture from this shop yet.' }
}
@@ -237,6 +241,25 @@ export function ago(iso) {
return `${Math.round(hrs / 24)} days ago`
}
// How long until a moment in the future.
//
// `ago` clamps at zero and reads a future timestamp as "just now", which is
// right for a heartbeat whose clock is a little ahead and completely wrong for
// an expiry: a code valid for a week rendered as "expires just now", which
// tells the operator not to bother handing it over.
export function until(iso) {
if (!iso) return 'never'
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '—'
const secs = (then - Date.now()) / 1000
if (secs <= 0) return 'expired'
const mins = Math.round(secs / 60)
if (mins < 60) return `in ${mins} min`
const hrs = Math.round(mins / 60)
if (hrs < 48) return `in ${hrs} h`
return `in ${Math.round(hrs / 24)} days`
}
export function Loading() {
return <div className="state"><span className="spinner" aria-hidden="true" />Loading…</div>
}

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>
)
}