Compare commits

...

2 Commits

Author SHA1 Message Date
Ben Stull e8e555d8a4 docs(invites): correct stale last_seen_at NULL claim
The provisioning docstring claimed the invitee row gets
`last_seen_at = NULL` as the "not yet arrived" discriminator. It does
not: the column is NOT NULL and the INSERT omits it, so it defaults to
datetime('now') — the longer note below already explained this, but the
bullet contradicted it. Rewrite the bullet to state the real behavior
(both timestamps default to now; the pending-invite state lives in the
unclaimed user_invite_tokens row) and note that consumers must treat a
pending-invite row as never-seen. Comment-only; no runtime change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:04:11 -07:00
Ben Stull 7e595b6e5e v0.31.3: admin Users "Last seen" = Never for unclaimed invites
An admin-created invite row showed a Last-seen timestamp identical to
Signed-up, implying the invitee had visited. users.last_seen_at is
NOT NULL DEFAULT (datetime('now')) and the invite INSERT sets neither
timestamp, so both default to row-creation time; last_seen_at only
advances on real authentication. An unclaimed invite has provably never
authenticated (the unclaimed state drives the PENDING INVITE badge), so
the Users tab now renders "Never" for the Last-seen cell of a pending
row. Signed-up (invite-created date) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 04:00:31 -07:00
5 changed files with 42 additions and 9 deletions
+20
View File
@@ -23,6 +23,26 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond release's steps in order — no A-to-B path is pre-computed beyond
that. that.
## 0.31.3 — 2026-05-30
**Patch — admin Users tab: "Last seen" reads "Never" for unclaimed
invites. Visual/logic only in `Admin.jsx`. A plain frontend rebuild
applies it.**
An admin-created invite row showed a real-looking "Last seen" timestamp
identical to "Signed up," implying the invitee had visited when they
hadn't. Cause: `users.last_seen_at` is `NOT NULL DEFAULT (datetime('now'))`
(`migrations/001_users_and_audit.sql`) and the invite INSERT
(`invites.py`) sets neither timestamp, so both default to the
row-creation instant; `last_seen_at` only advances on a real
authentication. Since an unclaimed invite has provably never
authenticated (that unclaimed state is exactly what drives the
"PENDING INVITE" badge), the Users tab now renders **"Never"** for the
Last-seen cell of a pending-invite row instead of the misleading
default. Signed-up (the invite-created date) is unchanged.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.2 — 2026-05-29 ## 0.31.2 — 2026-05-29
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in **Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
+1 -1
View File
@@ -1 +1 @@
0.31.2 0.31.3
+10 -4
View File
@@ -144,10 +144,16 @@ def create_invite(
The invitee `users` row is provisioned with: The invitee `users` row is provisioned with:
* `permission_state='granted'` the admin's hand is the grant; * `permission_state='granted'` the admin's hand is the grant;
the v0.8.0 self-serve `pending` queue is for the other path. the v0.8.0 self-serve `pending` queue is for the other path.
* `last_seen_at = NULL` the discriminator for "invited but * `created_at` / `last_seen_at` NOT set here, so both fall
not yet arrived" per the §16 / roadmap design. Every sign-in through to the column default `datetime('now')` (the column is
path stamps `last_seen_at` to now, so a NULL value means the `NOT NULL`; see `migrations/001_users_and_audit.sql` and the
invited user has not clicked through yet. longer note below). The "invited but not yet arrived" state is
therefore NOT carried on the user row it is the existence of
an unclaimed `user_invite_tokens` row, surfaced as the listing's
`pending_invite` field. Consumers that want a truthful
last-seen MUST treat a pending-invite row as never-seen rather
than trusting `last_seen_at` (every real sign-in path stamps it
to now, but an unclaimed invite has never hit one).
* `gitea_id = NULL`, `gitea_login = NULL` same as a v0.7.0 * `gitea_id = NULL`, `gitea_login = NULL` same as a v0.7.0
OTC-provisioned user; the OAuth identity is grandfathered if OTC-provisioned user; the OAuth identity is grandfathered if
the user ever lands through that path. the user ever lands through that path.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.31.2", "version": "0.31.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+10 -3
View File
@@ -326,7 +326,14 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
)} )}
</td> </td>
<TimeCell value={u.created_at} /> <TimeCell value={u.created_at} />
<TimeCell value={u.last_seen_at} /> {/* An unclaimed admin invite has provably never authenticated, so
last_seen_at is just the row-creation default (it equals
created_at). Render the truth "Never" rather than a
timestamp that reads like a real visit. */}
<TimeCell
value={pendingInvite ? null : u.last_seen_at}
emptyLabel={pendingInvite ? 'Never' : '—'}
/>
</tr> </tr>
{state === 'pending' && u.beta_request_reason ? ( {state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason"> <tr className="user-row-reason">
@@ -345,8 +352,8 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time // Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
// stack (date prominent, time quiet below) rather than letting a narrow // stack (date prominent, time quiet below) rather than letting a narrow
// column wrap the value mid-string. Falls back to an em-dash when absent. // column wrap the value mid-string. Falls back to an em-dash when absent.
function TimeCell({ value }) { function TimeCell({ value, emptyLabel = '—' }) {
if (!value) return <td className="muted"></td> if (!value) return <td className="muted">{emptyLabel}</td>
const [date, ...rest] = String(value).split(' ') const [date, ...rest] = String(value).split(' ')
const time = rest.join(' ') const time = rest.join(' ')
return ( return (