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>
This commit is contained in:
+46
-8
@@ -542,7 +542,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
|
||||
@@ -1607,12 +1610,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 +1865,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 +1946,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 +2000,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,8 @@ 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} />
|
||||
<TimeCell value={u.last_seen_at} />
|
||||
</tr>
|
||||
{state === 'pending' && u.beta_request_reason ? (
|
||||
<tr className="user-row-reason">
|
||||
@@ -334,6 +342,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 }) {
|
||||
if (!value) return <td className="muted">—</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