Release 0.11.0: trust device for 30 days

This commit is contained in:
Ben Stull
2026-05-28 03:39:28 -07:00
parent 7872b921ed
commit 6fb68a95c7
14 changed files with 1600 additions and 29 deletions
+56 -2
View File
@@ -72,6 +72,7 @@ import {
checkPasscode,
verifyPasscode,
setPasscode as apiSetPasscode,
startDeviceTrust,
} from '../api'
export default function Login() {
@@ -84,6 +85,13 @@ export default function Login() {
const [code, setCode] = useState('')
const [passcode, setPasscode] = useState('')
const [newPasscode, setNewPasscode] = useState('')
// v0.11.0 — "trust this device for 30 days" checkbox, shared by the
// OTC and passcode verify steps. The flag rides on the verify POST;
// a checked box mints a device-trust row server-side and sets the
// long-lived `rfc_device_trust` cookie. Defaults off so the user
// makes an explicit choice — auth credentials shouldn't persist by
// default.
const [trustDevice, setTrustDevice] = useState(false)
// v0.8.0 — capture-profile fields.
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
@@ -105,6 +113,28 @@ export default function Login() {
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
}, [step])
// v0.11.0 — on mount, try the device-trust cookie path. If the
// browser still carries a valid `rfc_device_trust` cookie from a
// prior "trust this device" gesture, the server re-establishes the
// session without any user input and we redirect home. The cookie
// is HttpOnly so we can't peek at it; we just call the endpoint and
// see whether it returns 200. 401 (no cookie / invalid / revoked)
// is the structural-silent case — the user proceeds to the email
// step normally. We do not surface any UI about the attempt; a
// failure should be invisible.
useEffect(() => {
let cancelled = false
;(async () => {
try {
await startDeviceTrust()
if (!cancelled) window.location.assign('/')
} catch (_) {
// No trusted device — fall through to the email step.
}
})()
return () => { cancelled = true }
}, [])
async function submitEmail(e) {
e.preventDefault()
if (!email.trim() || !email.includes('@')) {
@@ -143,7 +173,7 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
await verifyPasscode(email.trim(), passcode.trim())
await verifyPasscode(email.trim(), passcode.trim(), { trustDevice })
// Reload so App.jsx's getMe() picks up the fresh session. A
// returning passcode user is by definition already past the
// §6.1 capture step (they couldn't have set a passcode while
@@ -189,7 +219,7 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
await verifyOtc(email.trim(), code.trim())
await verifyOtc(email.trim(), code.trim(), { trustDevice })
// OTC verified — the server has signed in the user. Fetch the
// canonical /api/auth/me to decide where to land:
// * needs_profile → §6.1 capture (then /beta-pending).
@@ -378,6 +408,19 @@ export default function Login() {
required
disabled={busy}
/>
{/* v0.11.0 — trust device for 30 days. The checkbox lives
on the verify step so the user makes the trust gesture
in the same breath as signing in. Off by default; the
user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
<div className="otc-actions">
<button type="submit" disabled={busy || !passcode.trim()}>
{busy ? 'Signing in' : 'Sign in'}
@@ -420,6 +463,17 @@ export default function Login() {
required
disabled={busy}
/>
{/* v0.11.0 — trust device for 30 days. Same shape as the
passcode step; the user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
<div className="otc-actions">
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? 'Signing in' : 'Sign in'}