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
189 lines
6.5 KiB
JavaScript
189 lines
6.5 KiB
JavaScript
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('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
const submit = async (e) => {
|
|
e.preventDefault()
|
|
setBusy(true)
|
|
setError('')
|
|
try {
|
|
await api.login(email.trim(), password)
|
|
onSignedIn(await api.me())
|
|
} catch (err) {
|
|
// The server's own wording. It says the same thing for an unknown address
|
|
// and a wrong password on purpose - telling them apart turns this form
|
|
// into a way to find out who works at a customer.
|
|
setError(err.message)
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="signin">
|
|
<form className="card" onSubmit={submit}>
|
|
<span className="mark big" aria-hidden="true" />
|
|
<h1>Behavision</h1>
|
|
<p className="sub">Sign in to your company account.</p>
|
|
|
|
<label>
|
|
Email
|
|
<input
|
|
type="email" value={email} autoComplete="username" autoFocus required
|
|
onChange={e => setEmail(e.target.value)} placeholder="you@company.com"
|
|
/>
|
|
</label>
|
|
<label>
|
|
Password
|
|
<input
|
|
type="password" value={password} autoComplete="current-password" required
|
|
onChange={e => setPassword(e.target.value)}
|
|
/>
|
|
</label>
|
|
|
|
{error && <p className="error" role="alert">{error}</p>}
|
|
|
|
<button className="primary" disabled={busy || !email || !password}>
|
|
{busy ? 'Signing in…' : 'Sign in'}
|
|
</button>
|
|
<p className="foot">
|
|
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>
|
|
)
|
|
}
|