feat(admin): improve admin panel
normalise admin > users, sessions, drives
This commit is contained in:
@@ -171,6 +171,19 @@ export async function bindDpopIfPossible(): Promise<boolean> {
|
|||||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||||
body: JSON.stringify({ dpop_jkt: jkt })
|
body: JSON.stringify({ dpop_jkt: jkt })
|
||||||
});
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Bind attached the thumbprint to the SESSION row — but the
|
||||||
|
// browser is still carrying the JWT issued at OIDC callback
|
||||||
|
// / magic-link redemption BEFORE that bind, so its
|
||||||
|
// `cnf.jkt` claim is empty. Force a refresh cycle: the
|
||||||
|
// `rotate_session` path preserves the DPoP binding on the
|
||||||
|
// new row and mints a fresh JWT whose `cnf.jkt` reflects
|
||||||
|
// it. Without this, downstream code that keys off
|
||||||
|
// `CurrentUser::dpop_jkt` (admin sessions "is_current"
|
||||||
|
// highlight, DPoP verifier's expected_jkt lookup) sees
|
||||||
|
// None and treats the caller as unbound.
|
||||||
|
await tryRefresh();
|
||||||
|
}
|
||||||
return res.ok;
|
return res.ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug('dpop: bind endpoint call failed', err);
|
console.debug('dpop: bind endpoint call failed', err);
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
{
|
{
|
||||||
href: '/admin/drives',
|
href: '/admin/drives',
|
||||||
label: t('admin.drives', 'Drives'),
|
label: t('admin.drives', 'Drives'),
|
||||||
icon: 'folder',
|
icon: 'hdd',
|
||||||
section: 'admin-drives'
|
section: 'admin-drives'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
{
|
{
|
||||||
href: '/admin/oidc',
|
href: '/admin/oidc',
|
||||||
label: t('admin.oidc', 'OIDC / SSO'),
|
label: t('admin.oidc', 'OIDC / SSO'),
|
||||||
icon: 'key',
|
icon: 'building-shield',
|
||||||
section: 'admin-oidc'
|
section: 'admin-oidc'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ export const OxiIcons: Record<string, IconEntry> = {
|
|||||||
576,
|
576,
|
||||||
"M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM432 544a144 144 0 1 0 0-288 144 144 0 1 0 0 288zm22.6-144l36.7 36.7c6.2 6.2 6.2 16.4 0 22.6s-16.4 6.2-22.6 0l-36.7-36.7-36.7 36.7c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l36.7-36.7-36.7-36.7c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l36.7 36.7 36.7-36.7c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6L454.6 400z"
|
"M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM432 544a144 144 0 1 0 0-288 144 144 0 1 0 0 288zm22.6-144l36.7 36.7c6.2 6.2 6.2 16.4 0 22.6s-16.4 6.2-22.6 0l-36.7-36.7-36.7 36.7c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l36.7-36.7-36.7-36.7c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l36.7 36.7 36.7-36.7c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6L454.6 400z"
|
||||||
],
|
],
|
||||||
|
// Font Awesome Free 6.7.2 `building-shield` (office building with a
|
||||||
|
// shield overlay). Hand-added for the admin sidebar's OIDC / SSO
|
||||||
|
// entry — signals "identity provider federated to an external org"
|
||||||
|
// more directly than the generic `key` it replaced. Registry header
|
||||||
|
// says "regenerate from the source" but no generator lives in-repo,
|
||||||
|
// so hand-inserting matches the shape of every other entry (same
|
||||||
|
// precedent as `ranking-star`).
|
||||||
|
"building-shield": [
|
||||||
|
576,
|
||||||
|
"M0 48C0 21.5 21.5 0 48 0L336 0c26.5 0 48 21.5 48 48l0 159-42.4 17L304 224l-32 0c-8.8 0-16 7.2-16 16l0 32 0 24.2 0 7.8c0 .9 .1 1.7 .2 2.6c2.3 58.1 24.1 144.8 98.7 201.5c-5.8 2.5-12.2 3.9-18.9 3.9l-96 0 0-80c0-26.5-21.5-48-48-48s-48 21.5-48 48l0 80-96 0c-26.5 0-48-21.5-48-48L0 48zM80 224c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zm80 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zM64 112l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16L80 96c-8.8 0-16 7.2-16 16zM176 96c-8.8 0-16 7.2-16 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0zm80 16l0 32c0 8.8 7.2 16 16 16l32 0c8.8 0 16-7.2 16-16l0-32c0-8.8-7.2-16-16-16l-32 0c-8.8 0-16 7.2-16 16zM423.1 225.7c5.7-2.3 12.1-2.3 17.8 0l120 48C570 277.4 576 286.2 576 296c0 63.3-25.9 168.8-134.8 214.2c-5.9 2.5-12.6 2.5-18.5 0C313.9 464.8 288 359.3 288 296c0-9.8 6-18.6 15.1-22.3l120-48zM527.4 312L432 273.8l0 187.8c68.2-33 91.5-99 95.4-149.7z"
|
||||||
|
],
|
||||||
"calendar": [
|
"calendar": [
|
||||||
512,
|
512,
|
||||||
"M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z"
|
"M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z"
|
||||||
|
|||||||
@@ -2677,14 +2677,15 @@
|
|||||||
{@const pct = quotaPct(u)}
|
{@const pct = quotaPct(u)}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="user-cell">
|
<div class="user-vignette-cell">
|
||||||
<strong>
|
<UserVignette
|
||||||
{u.username || u.email}
|
userId={u.id}
|
||||||
|
fallbackLabel={u.username || u.email}
|
||||||
|
fallbackSublabel={u.email}
|
||||||
|
/>
|
||||||
{#if isSelf(u)}
|
{#if isSelf(u)}
|
||||||
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
|
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</strong>
|
|
||||||
<span class="muted">{u.email}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -2964,27 +2965,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if tab === 'sessions'}
|
{:else if tab === 'sessions'}
|
||||||
<section class="admin-section" data-testid="admin-sessions-section">
|
<div class="bar">
|
||||||
<h2>{t('admin.sessions.title', 'Sessions')}</h2>
|
<label class="bar__filter">
|
||||||
<p class="muted">
|
{t('admin.sessions.filter_user', 'User (UUID)')}
|
||||||
{t(
|
|
||||||
'admin.sessions.help',
|
|
||||||
'Active sign-in sessions across all users. A locked icon means the session is bound to a browser keypair (DPoP) — a stolen cookie alone cannot use it. Revoke to force the browser to re-authenticate on its next request.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="admin-toolbar">
|
|
||||||
<label>
|
|
||||||
{t('admin.sessions.filter_user', 'User (UUID)')}:
|
|
||||||
<input
|
<input
|
||||||
class="input"
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="00000000-…"
|
placeholder="00000000-…"
|
||||||
data-testid="admin-sessions-user-filter-input"
|
data-testid="admin-sessions-user-filter-input"
|
||||||
bind:value={sessionsFilterUserId}
|
bind:value={sessionsFilterUserId}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="bar__toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
data-testid="admin-sessions-include-revoked-checkbox"
|
data-testid="admin-sessions-include-revoked-checkbox"
|
||||||
@@ -2998,20 +2989,16 @@
|
|||||||
onclick={() => void loadSessions()}
|
onclick={() => void loadSessions()}
|
||||||
disabled={sessionsLoading}
|
disabled={sessionsLoading}
|
||||||
>
|
>
|
||||||
|
<Icon name="sync-alt" />
|
||||||
{sessionsLoading
|
{sessionsLoading
|
||||||
? t('common.loading', 'Loading…')
|
? t('common.loading', 'Loading…')
|
||||||
: t('admin.sessions.refresh', 'Refresh')}
|
: t('admin.sessions.refresh', 'Refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if sessionsError}
|
{#if sessionsError}
|
||||||
<div class="alert alert-error" data-testid="admin-sessions-error">
|
<p class="status status--error" data-testid="admin-sessions-error">{sessionsError}</p>
|
||||||
{sessionsError}
|
{:else}
|
||||||
</div>
|
<table class="table" data-testid="admin-sessions-table">
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="admin-table" data-testid="admin-sessions-table">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t('admin.sessions.col_user', 'User')}</th>
|
<th>{t('admin.sessions.col_user', 'User')}</th>
|
||||||
@@ -3031,19 +3018,21 @@
|
|||||||
class:muted={!s.is_active}
|
class:muted={!s.is_active}
|
||||||
class:current-session={s.is_current}
|
class:current-session={s.is_current}
|
||||||
>
|
>
|
||||||
<td class="mono" title={s.user_id}>
|
<td>
|
||||||
{s.user_id.slice(0, 8)}…
|
<div class="user-vignette-cell">
|
||||||
|
<UserVignette userId={s.user_id} fallbackLabel={s.user_id} />
|
||||||
{#if s.is_current}
|
{#if s.is_current}
|
||||||
<span
|
<span
|
||||||
class="badge badge-info"
|
class="badge badge--self"
|
||||||
title={t(
|
title={t(
|
||||||
'admin.sessions.current_tooltip',
|
'admin.sessions.current_tooltip',
|
||||||
"This is the session you're using right now — revoking it will log you out."
|
"This is the session you're using right now — revoking it will log you out."
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t('admin.sessions.current', 'you')}
|
{t('admin.you_badge', 'you')}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{new Date(s.created_at).toLocaleString()}</td>
|
<td>{new Date(s.created_at).toLocaleString()}</td>
|
||||||
<td>{new Date(s.expires_at).toLocaleString()}</td>
|
<td>{new Date(s.expires_at).toLocaleString()}</td>
|
||||||
@@ -3054,13 +3043,15 @@
|
|||||||
<td>
|
<td>
|
||||||
{#if s.is_bound}
|
{#if s.is_bound}
|
||||||
<span
|
<span
|
||||||
|
class="bound-cell"
|
||||||
title={t(
|
title={t(
|
||||||
'admin.sessions.bound_tooltip',
|
'admin.sessions.bound_tooltip',
|
||||||
{ prefix: s.dpop_jkt_prefix ?? '' },
|
{ prefix: s.dpop_jkt_prefix ?? '' },
|
||||||
'DPoP-bound (jkt {{prefix}}…)'
|
'DPoP-bound (jkt {{prefix}}…)'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
🔒 {s.dpop_jkt_prefix ?? ''}
|
<Icon name="lock" />
|
||||||
|
<span class="mono">{s.dpop_jkt_prefix ?? ''}</span>
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="muted">{t('admin.sessions.unbound', 'unbound')}</span>
|
<span class="muted">{t('admin.sessions.unbound', 'unbound')}</span>
|
||||||
@@ -3068,13 +3059,15 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{#if s.is_revoked}
|
{#if s.is_revoked}
|
||||||
<span class="badge badge-danger">
|
<span class="badge badge--inactive">
|
||||||
{t('admin.sessions.revoked', 'revoked')}
|
{t('admin.sessions.revoked', 'revoked')}
|
||||||
</span>
|
</span>
|
||||||
{:else if !s.is_active}
|
{:else if !s.is_active}
|
||||||
<span class="badge">{t('admin.sessions.expired', 'expired')}</span>
|
<span class="badge badge--inactive">
|
||||||
|
{t('admin.sessions.expired', 'expired')}
|
||||||
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge badge-ok">
|
<span class="badge badge--active">
|
||||||
{t('admin.sessions.active', 'active')}
|
{t('admin.sessions.active', 'active')}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -3082,14 +3075,14 @@
|
|||||||
<td>
|
<td>
|
||||||
{#if !s.is_revoked}
|
{#if !s.is_revoked}
|
||||||
<button
|
<button
|
||||||
class="btn btn-danger btn-sm"
|
class="icon-btn icon-btn--danger"
|
||||||
data-testid={`admin-sessions-revoke-btn-${s.id}`}
|
data-testid={`admin-sessions-revoke-btn-${s.id}`}
|
||||||
|
title={t('admin.sessions.revoke', 'Revoke')}
|
||||||
|
aria-label={t('admin.sessions.revoke', 'Revoke')}
|
||||||
onclick={() => void onRevokeSession(s.id, s.is_current)}
|
onclick={() => void onRevokeSession(s.id, s.is_current)}
|
||||||
disabled={sessionRevokingId === s.id}
|
disabled={sessionRevokingId === s.id}
|
||||||
>
|
>
|
||||||
{sessionRevokingId === s.id
|
<Icon name="trash-alt" />
|
||||||
? t('common.working', 'Working…')
|
|
||||||
: t('admin.sessions.revoke', 'Revoke')}
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
@@ -3104,8 +3097,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
{/if}
|
||||||
</section>
|
|
||||||
{:else if tab === 'mounts'}
|
{:else if tab === 'mounts'}
|
||||||
<section class="admin-section" data-testid="admin-mounts-section">
|
<section class="admin-section" data-testid="admin-mounts-section">
|
||||||
<h2>{t('admin.mounts.title', 'External File Mounts')}</h2>
|
<h2>{t('admin.mounts.title', 'External File Mounts')}</h2>
|
||||||
@@ -4167,10 +4159,10 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Admin sessions panel — accent the caller's own row so revoking
|
/* Admin sessions panel — accent the caller's own row so revoking
|
||||||
it can't happen by muscle memory. Left border + tinted bg pull
|
it can't happen by muscle memory. Left-border stripe matches how
|
||||||
the eye; JS confirms with an escalated message on top of that. */
|
Users' table calls out the caller via the `you` badge; JS
|
||||||
.current-session {
|
confirms with an escalated message on top of the visual cue. */
|
||||||
background: var(--color-bg-accent-subtle, color-mix(in srgb, var(--color-accent) 8%, transparent));
|
.current-session td:first-child {
|
||||||
border-left: 3px solid var(--color-accent);
|
border-left: 3px solid var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5127,6 +5119,60 @@
|
|||||||
.bar {
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3, 0.75rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sessions-panel toolbar items — filter input + include-revoked
|
||||||
|
checkbox pushed to the left, refresh button anchored right. */
|
||||||
|
.bar__filter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2, 0.5rem);
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar__filter input {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md, 4px);
|
||||||
|
background: var(--color-bg-input, var(--color-bg));
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
min-width: 20ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2, 0.5rem);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin table user cell — UserVignette (avatar + name + email)
|
||||||
|
with the "you" badge parked to its right when this row belongs
|
||||||
|
to the caller. Flex + gap keeps them shoulder-to-shoulder
|
||||||
|
without collapsing on narrow columns. Shared across Users +
|
||||||
|
Sessions tabs; both benefit from the same avatar/name/email
|
||||||
|
presentation. */
|
||||||
|
.user-vignette-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2, 0.5rem);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DPoP-bound cell: lock icon + short jkt prefix, kept tight so
|
||||||
|
the column doesn't inflate on wide viewports. */
|
||||||
|
.bound-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1, 0.25rem);
|
||||||
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
@@ -5142,6 +5188,18 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Row hover highlight — covers Users / Sessions / Drives (every
|
||||||
|
admin table renders through `.table`). Header rows and empty-
|
||||||
|
state rows are excluded via `tbody` scoping. `transition` keeps
|
||||||
|
the tint from feeling twitchy on fast pointer movement. */
|
||||||
|
.table tbody tr {
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.user-cell {
|
.user-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user