Compare commits

...

7 Commits

Author SHA1 Message Date
Ben Stull cdbc8078b6 Patch 0.31.5: pin transitive security floors (tqdm, idna)
backend/requirements.txt carries only loose `>=` direct constraints and
no compiled lockfile, so osv-scanner v2 resolves the transitive graph and
pins each unpinned transitive package to its MINIMUM published version —
surfacing CVEs in ancient releases pip would never install. That fired a
phantom HIGH (tqdm 4.9.0, GHSA-r7q7-xcjw-qx8q) plus an idna finding on the
OHM Patchwatch radar, even though the live deployment runs patched versions.

Add `tqdm>=4.66.3` and `idna>=3.15` to anchor the resolver to the patched
range. osv-scanner now reports zero findings. No direct-import / schema /
API / config change; deployed installs already satisfy the floors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 07:56:14 -07:00
Ben Stull 551d240967 Merge pull request 'Release v0.31.4: fix invisible light-surface btn-link + harmonize RFC breadcrumb action bar' (#5) from release/v0.31.4-button-ux into main 2026-06-01 15:20:33 +00:00
Ben Stull 76c82a5e96 v0.31.4: fix invisible light-surface btn-link + harmonize RFC breadcrumb action bar
`.btn-link` was a dark-header utility (white text on translucent white)
reused on light surfaces — the RFC breadcrumb action bar, PR diff toggle,
modals, discussion panel — where it rendered white-on-white and looked
like missing buttons (reported: "Metadata"/"Claim ownership"/"Invitations"
absent on a super-draft header). Root-cause fix:

- Base .btn-link is now a light-surface secondary button (white fill,
  hairline border, dark label); the dark-header "Sign out" keeps the
  translucent-on-dark treatment via an .app-header .btn-link scope.
- .breadcrumb-actions normalizes the toggle, filled CTAs, and secondary
  buttons to one height/radius/type as a single control group, and
  flex-wraps instead of clipping off the right edge.
- Diff-mode active toggle now reads as clearly selected (filled ink).

CSS-only; patch release, plain frontend rebuild applies it. No upgrade
steps. Driver session 0059.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:22:19 -07:00
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
Ben Stull 1d716d0cb8 v0.31.2: landing welcome panel spacing
Visual-only patch. The "/" welcome read-view was jammed against the
catalog divider with no top offset and loose paragraph rhythm: the
.main-pane §8 override (padding:0; display:flex) shadows the padded
read-view rule, so the pane gives no padding, and .welcome (max-width
only) never compensated. The welcome surface now owns its breathing
room — 56px top / 48px side gutters, a 680px measure, a text-3xl hero,
and even --space-8 paragraph spacing at --leading-relaxed. Covers both
the signed-out and signed-in welcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:54:29 -07:00
Ben Stull f96883506e v0.31.1: admin Users tab + header UX polish
Visual-only patch atop v0.31.0. CSS plus markup/structure in Admin.jsx;
no API, schema, config, overlay, or secret change — a plain frontend
rebuild applies it.

Two latent CSS defects fixed:
- .invite-badge had no rule, so "(pending invite)" rendered as bare
  parenthetical text; it's now a quiet amber pill.
- .btn-link-quiet never reset native <button> chrome, so the admin
  Revoke/Grant/Remove buttons, the modal close ×, and Login/BetaPending
  link-buttons kept the browser's grey button box. The reset the
  .otc-login scope already carried is folded into the base rule.

Users tab: table headers no longer wrap (WRITE-MUTED), timestamps
render as a date-over-time stack, the duplicated subline email is
de-duped, the Create-user-+-invite action moves flush-right beside the
title, and intro DB-column refs read as quiet chips.

Header: the Inbox (§15.2) trigger was styled for a light surface
(gray-200 border, gray-50 hover) and rendered as a pale box that went
white-on-white on hover; restyled to the nav-link vocabulary
(borderless, gray-300 icon → white on a faint translucent hover).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:32:47 -07:00
7 changed files with 331 additions and 46 deletions
+147
View File
@@ -23,6 +23,153 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.31.5 — 2026-06-03
**Patch — supply-chain hygiene: pin security floors on two unbounded
*transitive* dependencies so vulnerability scanners stay anchored to the
patched range. No direct-import, schema, API, config, overlay, or secret
change; deployed installs already satisfy these floors, so no rebuild or
redeploy is required to become compliant. No upgrade steps.**
`backend/requirements.txt` declares only loose (`>=`) direct constraints
and ships no compiled lockfile. osv-scanner v2 resolves the *transitive*
graph and, for any unpinned transitive package, pins it to its **minimum**
published version — surfacing CVEs in ancient releases that `pip install`
would never actually choose. Two such phantoms were firing:
- **`tqdm`** (pulled by `google-generativeai`) resolved to `4.9.0` (2016),
flagging `GHSA-r7q7-xcjw-qx8q` (HIGH) and friends — fixed in 4.66.3.
- **`idna`** (pulled by `httpx` / `anthropic`) resolved to `3.9.0`,
flagging `GHSA-65pc-fj4g-8rjx` — fixed in 3.15.
Adding `tqdm>=4.66.3` and `idna>=3.15` anchors the resolver to the patched
range. The live deployment already runs patched versions (e.g. tqdm 4.67.x);
this only corrects what the scanner *sees*. The proper long-term fix is a
fully-compiled lockfile — tracked separately.
## 0.31.4 — 2026-06-01
**Patch — bug fix + UI polish: secondary buttons that were invisible on
light surfaces now render legibly, and the RFC view's breadcrumb action
bar is harmonized into one coherent control group. CSS-only
(`frontend/src/App.css`); no schema, API, config, overlay, or secret
change — a plain frontend rebuild applies it. No upgrade steps. Shipped
from driver session 0059.0.**
`.btn-link` was authored as a *dark-header* utility — white text on a
translucent-white fill (`rgba(255,255,255,0.15)`), the established
on-dark pattern for the app header's "Sign out". But the same class is
reused on **light** surfaces: the RFC breadcrumb action bar
(`RFCView.jsx`), the PR view's diff-mode toggle and "Edit title" control
(`PRView.jsx`), the invitations and inbox modals, and the discussion
panel. On those near-white backgrounds the buttons were white-on-white —
present in the DOM, fully functional, but visually invisible. The
reported symptom: on a super-draft's header, "Metadata", "Claim
ownership", and "Invitations" looked *missing*, while the filled CTAs
("Start Contributing", "Graduate to RFC repo") rendered fine because
their fill carried them.
The fix is root-cause, not a per-site patch:
- The base `.btn-link` rule is now a proper light-surface secondary
button (white fill, hairline `--c-gray-300` border, `--c-gray-700`
label, hover darkens both). This corrects every light-surface reuse at
once.
- The original translucent-on-dark treatment is preserved for the one
legitimate dark-surface use via an `.app-header .btn-link` scope, so
the header "Sign out" is unchanged.
- The breadcrumb action bar (`.breadcrumb-actions`) normalizes every
action — the discuss/contribute toggle, the filled CTAs, and the
secondary buttons — to one height, radius, and type scale, so the row
reads as a single intentional control group. The bar now `flex-wrap`s
instead of clipping buttons off the right edge when the set is wide.
- The diff-mode toggle's active option now reads as clearly selected
(filled ink) rather than relying on a weight change alone.
Smooth hover transitions and the keyboard focus ring were already
provided globally by the v0.21.0 interaction-polish layer, so this
change adds no new motion or focus rules — it only corrects resting-state
color/contrast and harmonizes sizing.
## 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
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
`App.css` (`.welcome`). A plain frontend rebuild applies it.**
The welcome read-view was jammed against the catalog divider with no
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
carries a bare `.main-pane { padding: 0; display: flex }` override (the
§8 three-column RFC shell) that shadows the earlier padded read-view
rule, so the pane provides no padding — and `.welcome` (just
`max-width`) never compensated. The welcome surface now owns its own
breathing room: 56px top / 48px side gutters, a capped 680px measure,
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
rhythm at `--leading-relaxed`. Applies to both the signed-out and
signed-in welcome (same `.welcome` class).
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.1 — 2026-05-29
**Patch — admin Users tab + header UX polish. Visual only: CSS plus
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
secret change). A plain frontend rebuild applies it.**
Two latent CSS defects fixed:
- **`.invite-badge` had no rule.** The "(pending invite)" marker on
admin-created-but-unclaimed user rows rendered as bare parenthetical
text. It's now a quiet amber pill, consistent with the other status
badges.
- **`.btn-link-quiet` never reset native button chrome.** Used as a
bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
close ×, and link-buttons in Login / BetaPending), it kept the
browser's default grey button box. The reset that the `.otc-login`
scope already carried is folded into the base rule, so every
`btn-link-quiet` is now a true quiet link.
Users-tab cleanups, all token-based:
- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
two lines); timestamps render as an intentional date-over-time stack
instead of a ragged mid-value wrap; the duplicated email in a row's
subline (the handle already *is* the email when there's no Gitea
login) is de-duplicated; the "Create user + invite" action moves
flush-right beside the title; inline DB-column references in the
intro copy read as quiet chips.
Header:
- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
light-surface treatment — a `gray-200` border and a `gray-50` hover —
that rendered as a pale box in the nav and went white-background /
white-icon (invisible) on hover. It now speaks the nav-link
vocabulary (`.header-about` et al.): borderless, `gray-300` icon
brightening to white on a faint translucent hover, unread badge
unchanged.
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
## 0.31.0 — 2026-05-29
**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
+1 -1
View File
@@ -1 +1 @@
0.31.0
0.31.5
+10 -4
View File
@@ -144,10 +144,16 @@ def create_invite(
The invitee `users` row is provisioned with:
* `permission_state='granted'` the admin's hand is the grant;
the v0.8.0 self-serve `pending` queue is for the other path.
* `last_seen_at = NULL` the discriminator for "invited but
not yet arrived" per the §16 / roadmap design. Every sign-in
path stamps `last_seen_at` to now, so a NULL value means the
invited user has not clicked through yet.
* `created_at` / `last_seen_at` NOT set here, so both fall
through to the column default `datetime('now')` (the column is
`NOT NULL`; see `migrations/001_users_and_audit.sql` and the
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
OTC-provisioned user; the OAuth identity is grandfathered if
the user ever lands through that path.
+9
View File
@@ -9,3 +9,12 @@ google-generativeai>=0.8
openai>=1.50
PyYAML>=6.0
bcrypt>=4.2
# Security floors on unbounded *transitive* deps (not direct imports). osv-scanner
# resolves an unpinned transitive package to its MINIMUM PyPI version, which surfaces
# CVEs in ancient releases pip would never actually install. These floors keep the
# Patchwatch radar (and any scan) anchored to the patched range.
# tqdm <- google-generativeai ; GHSA-r7q7-xcjw-qx8q et al., fixed in 4.66.3
# idna <- httpx / anthropic ; GHSA-65pc-fj4g-8rjx, fixed in 3.15
tqdm>=4.66.3
idna>=3.15
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.31.0",
"version": "0.31.5",
"type": "module",
"scripts": {
"dev": "vite",
+111 -18
View File
@@ -34,13 +34,31 @@
.role-owner { background: var(--c-warning-accent); }
.role-admin { background: var(--c-accent-strong); }
/* The default surface for .btn-link is LIGHT (breadcrumb bar, PR view,
* modals, discussion panel, inbox). It renders as a quiet secondary
* button: white fill, hairline border, dark label. The dark app-header
* reuse ("Sign out") opts back into the translucent-on-dark treatment
* via the .app-header scope below. (Before v0.31.4 the base rule WAS the
* dark-header style, so every light-surface .btn-link was white-on-near-
* white and effectively invisible.) */
.btn-link {
color: var(--c-white); text-decoration: none;
background: var(--color-on-dark-soft);
display: inline-flex; align-items: center;
color: var(--c-gray-700); text-decoration: none;
background: var(--c-white);
border: 1px solid var(--c-gray-300);
border-radius: var(--radius-md); padding: 4px 10px;
font-size: var(--text-base);
font-size: var(--text-base); cursor: pointer;
}
.btn-link:hover {
background: var(--c-gray-50); border-color: var(--c-gray-400); color: var(--c-ink);
}
/* Dark header reuse: restore the original translucent-white treatment. */
.app-header .btn-link {
color: var(--c-white); background: var(--color-on-dark-soft); border-color: transparent;
}
.app-header .btn-link:hover {
color: var(--c-white); background: var(--color-on-dark-hover); border-color: transparent;
}
.btn-link:hover { background: var(--color-on-dark-hover); }
.btn-signin-header {
color: var(--c-white); text-decoration: none;
@@ -189,9 +207,27 @@
padding: 32px 48px;
}
.welcome { max-width: 640px; }
.welcome h1 { font-size: var(--text-2xl); font-weight: 600; margin: 0 0 16px; }
.welcome p { line-height: 1.7; color: var(--c-gray-600); }
/* `.main-pane` is the §8 flex shell and carries no padding (the bare
`.main-pane` override below shadows the padded read-view rule), so the
welcome surface owns its own breathing room top offset, comfortable
side gutters, and a capped measure for readable line length. */
.welcome {
max-width: 680px;
padding: 56px 48px 64px;
}
.welcome h1 {
font-size: var(--text-3xl); font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 var(--space-8);
}
.welcome p {
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--c-gray-600);
margin: 0 0 var(--space-8);
}
.welcome p:last-child { margin-bottom: 0; }
.welcome strong { color: var(--c-gray-800); }
/* --- RFC / Proposal view (read-only for slice 1) --- */
@@ -542,7 +578,10 @@
font-size: var(--text-md); font-weight: 600; text-decoration: none;
}
.beta-pending-actions .btn-primary:hover { background: var(--c-gray-700); }
.btn-link-quiet { color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base); }
.btn-link-quiet {
background: none; border: none; padding: 0; cursor: pointer;
color: var(--c-gray-500); text-decoration: none; font-size: var(--text-base);
}
.btn-link-quiet:hover { color: var(--c-ink); text-decoration: underline; }
/* v0.8.0 thin "your beta access is in review" banner. Shown on every
@@ -578,7 +617,7 @@
}
.rfc-breadcrumb {
display: flex; align-items: center; gap: 8px;
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--c-gray-200);
background: var(--c-gray-50);
@@ -591,7 +630,25 @@
}
.breadcrumb-sep { color: var(--c-gray-300); }
.breadcrumb-meta { color: var(--c-gray-500); font-size: var(--text-sm); }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.breadcrumb-actions {
margin-left: auto;
display: flex; flex-wrap: wrap; justify-content: flex-end;
gap: 8px; align-items: center; min-width: 0;
}
/* Normalize every action in the bar to one height + shape so the mode
* toggle, the filled CTAs (Start Contributing / Open PR / Graduate) and
* the secondary buttons (Metadata / Claim ownership / Invitations / )
* line up as a single, intentional control group. Higher specificity
* than the per-variant rules, so it harmonizes their size/radius/type
* without disturbing each variant's fill colors. */
.breadcrumb-actions > button,
.breadcrumb-actions > a {
display: inline-flex; align-items: center;
height: 30px; padding: 0 12px;
border-radius: var(--radius-md);
font-size: var(--text-sm); font-weight: 600;
white-space: nowrap;
}
.btn-mode-toggle {
font-size: var(--text-sm); font-weight: 600;
@@ -1423,7 +1480,8 @@
font-size: var(--text-sm);
}
.diff-mode-toolbar .btn-link.active {
font-weight: 600; color: var(--c-ink);
font-weight: 600; color: var(--c-white);
background: var(--c-ink); border-color: var(--c-ink);
}
.pr-diff-accent {
margin-left: auto;
@@ -1607,12 +1665,18 @@
/* ---- §15 / Slice 6: inbox, badge, toasts ---- */
/* Lives on the dark header so it speaks the nav-link vocabulary
(.header-about et al.): borderless, gray-300 icon brightening to white
on a faint translucent-white hover. The old light-gray border + gray-50
hover were styled for a light surface and rendered as a pale box that
went white-on-white (invisible icon) on hover. */
.inbox-trigger {
position: relative; background: transparent; border: 1px solid var(--c-gray-200);
border-radius: var(--radius-md); padding: 4px 10px; cursor: pointer; font-size: var(--text-lg);
margin-right: 12px;
position: relative; display: inline-flex; align-items: center; justify-content: center;
background: transparent; border: none;
color: var(--c-gray-300); cursor: pointer;
padding: 5px 8px; border-radius: var(--radius-sm);
}
.inbox-trigger:hover { background: var(--c-gray-50); }
.inbox-trigger:hover { color: var(--c-white); background: rgba(255,255,255,0.08); }
.inbox-trigger .badge {
position: absolute; top: -6px; right: -6px;
background: #dc2626; color: white; font-size: var(--text-2xs);
@@ -1856,7 +1920,7 @@
}
.settings-table th, .admin-table th {
text-align: left; padding: 6px 8px;
font-size: var(--text-xs); text-transform: uppercase;
font-size: var(--text-xs); text-transform: uppercase; white-space: nowrap;
color: var(--c-gray-500); letter-spacing: 0.05em; font-weight: 600;
border-bottom: 1px solid var(--c-gray-200);
}
@@ -1937,7 +2001,21 @@
.admin-tab-header h2 {
margin: 0 0 4px; font-size: var(--text-xl); font-weight: 700;
}
.admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); }
.admin-tab-header p { margin: 0 0 24px; font-size: var(--text-base); max-width: 70ch; line-height: var(--leading-normal); }
/* Title row: heading on the left, primary action flush right. */
.admin-tab-heading {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--space-7); margin-bottom: var(--space-2);
}
.admin-tab-heading h2 { margin: 0; }
.admin-tab-actions { flex-shrink: 0; }
/* Inline DB-column references in admin copy read as quiet chips, not raw
monospace runs jammed against the sans body. */
.admin-tab-header code {
font-family: var(--font-mono); font-size: var(--text-sm);
background: var(--c-gray-100); color: var(--c-gray-700);
padding: 1px 5px; border-radius: var(--radius-sm);
}
.admin-section-h {
font-size: var(--text-base); text-transform: uppercase;
letter-spacing: 0.05em; color: var(--c-gray-500);
@@ -1977,8 +2055,23 @@
.allowlist-add .btn-primary:hover:not(:disabled) { background: var(--c-gray-700); }
.allowlist-add .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.user-cell { display: flex; flex-direction: column; gap: 1px; }
.user-cell { display: flex; flex-direction: column; gap: 2px; }
.user-cell-handle { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.user-handle { font-weight: 500; color: var(--c-gray-900); }
/* "(pending invite)" an unclaimed admin-created row. A quiet amber pill
so the admin spots it at a glance without it shouting. */
.invite-badge {
font-size: var(--text-2xs); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 1px 6px; border-radius: var(--radius-pill);
background: var(--c-warning-bg); color: var(--c-warning-fg);
white-space: nowrap;
}
/* Timestamps: an intentional date-over-time stack rather than a ragged
mid-value wrap. nowrap keeps each line whole. */
.user-when { white-space: nowrap; }
.user-when-date { display: block; color: var(--c-gray-700); }
.user-when-time { display: block; font-size: var(--text-xs); }
.mute-toggle {
display: inline-flex; align-items: center; gap: 6px;
font-size: var(--text-base); cursor: pointer;
+52 -22
View File
@@ -181,7 +181,20 @@ function UsersTab() {
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Users</h2>
<div className="admin-tab-heading">
<h2>Users</h2>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</div>
<p className="muted">
The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code>
@@ -190,17 +203,6 @@ function UsersTab() {
retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them.
</p>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</header>
{error && <p className="settings-note warning">{error}</p>}
{inviteModalOpen && (
@@ -270,21 +272,27 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
// the admin sees at a glance which rows are real users vs. unclaimed
// invites.
const pendingInvite = u.pending_invite
// When there's no gitea_login the handle already IS the email, so the
// subline would otherwise repeat it. Only append the email when it adds
// something the handle doesn't already show.
const showEmail = u.email && u.email !== handle
return (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>(pending invite)</span>
)}
<div className="user-cell-handle">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>pending invite</span>
)}
</div>
<span className="muted">
{fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''}
{showEmail ? ` · ${u.email}` : ''}
</span>
</div>
</td>
@@ -317,8 +325,15 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.created_at || '—'}</td>
<td className="muted">{u.last_seen_at || '—'}</td>
<TimeCell value={u.created_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>
{state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason">
@@ -334,6 +349,21 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
)
}
// 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
// column wrap the value mid-string. Falls back to an em-dash when absent.
function TimeCell({ value, emptyLabel = '—' }) {
if (!value) return <td className="muted">{emptyLabel}</td>
const [date, ...rest] = String(value).split(' ')
const time = rest.join(' ')
return (
<td className="user-when">
<span className="user-when-date">{date}</span>
{time && <span className="user-when-time muted">{time}</span>}
</td>
)
}
function PermissionCell({ user: u, busy, onFlipPermission }) {
const state = u.permission_state || 'granted'
const decidedSuffix = u.permission_decided_at