Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdbc8078b6 | |||
| 551d240967 | |||
| 76c82a5e96 | |||
| e8e555d8a4 | |||
| 7e595b6e5e | |||
| 1d716d0cb8 | |||
| f96883506e |
+147
@@ -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
|
||||
|
||||
+10
-4
@@ -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,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,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.31.0",
|
||||
"version": "0.31.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+111
-18
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user