feat(admin): improve admin panel

normalise admin > users, sessions, drives
This commit is contained in:
Edouard Vanbelle
2026-08-09 05:57:20 +02:00
parent 10d831b204
commit 62475193ff
4 changed files with 229 additions and 147 deletions
+13
View File
@@ -171,6 +171,19 @@ export async function bindDpopIfPossible(): Promise<boolean> {
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
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;
} catch (err) {
console.debug('dpop: bind endpoint call failed', err);
+2 -2
View File
@@ -95,7 +95,7 @@
{
href: '/admin/drives',
label: t('admin.drives', 'Drives'),
icon: 'folder',
icon: 'hdd',
section: 'admin-drives'
},
{
@@ -107,7 +107,7 @@
{
href: '/admin/oidc',
label: t('admin.oidc', 'OIDC / SSO'),
icon: 'key',
icon: 'building-shield',
section: 'admin-oidc'
},
{
+11
View File
@@ -70,6 +70,17 @@ export const OxiIcons: Record<string, IconEntry> = {
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"
],
// 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": [
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"
+103 -45
View File
@@ -2677,14 +2677,15 @@
{@const pct = quotaPct(u)}
<tr>
<td>
<div class="user-cell">
<strong>
{u.username || u.email}
<div class="user-vignette-cell">
<UserVignette
userId={u.id}
fallbackLabel={u.username || u.email}
fallbackSublabel={u.email}
/>
{#if isSelf(u)}
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
{/if}
</strong>
<span class="muted">{u.email}</span>
</div>
</td>
<td>
@@ -2964,27 +2965,17 @@
</div>
{/if}
{:else if tab === 'sessions'}
<section class="admin-section" data-testid="admin-sessions-section">
<h2>{t('admin.sessions.title', 'Sessions')}</h2>
<p class="muted">
{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)')}:
<div class="bar">
<label class="bar__filter">
{t('admin.sessions.filter_user', 'User (UUID)')}
<input
class="input"
type="text"
placeholder="00000000-…"
data-testid="admin-sessions-user-filter-input"
bind:value={sessionsFilterUserId}
/>
</label>
<label>
<label class="bar__toggle">
<input
type="checkbox"
data-testid="admin-sessions-include-revoked-checkbox"
@@ -2998,20 +2989,16 @@
onclick={() => void loadSessions()}
disabled={sessionsLoading}
>
<Icon name="sync-alt" />
{sessionsLoading
? t('common.loading', 'Loading…')
: t('admin.sessions.refresh', 'Refresh')}
</button>
</div>
{#if sessionsError}
<div class="alert alert-error" data-testid="admin-sessions-error">
{sessionsError}
</div>
{/if}
<div class="table-wrap">
<table class="admin-table" data-testid="admin-sessions-table">
<p class="status status--error" data-testid="admin-sessions-error">{sessionsError}</p>
{:else}
<table class="table" data-testid="admin-sessions-table">
<thead>
<tr>
<th>{t('admin.sessions.col_user', 'User')}</th>
@@ -3031,19 +3018,21 @@
class:muted={!s.is_active}
class:current-session={s.is_current}
>
<td class="mono" title={s.user_id}>
{s.user_id.slice(0, 8)}…
<td>
<div class="user-vignette-cell">
<UserVignette userId={s.user_id} fallbackLabel={s.user_id} />
{#if s.is_current}
<span
class="badge badge-info"
class="badge badge--self"
title={t(
'admin.sessions.current_tooltip',
"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>
{/if}
</div>
</td>
<td>{new Date(s.created_at).toLocaleString()}</td>
<td>{new Date(s.expires_at).toLocaleString()}</td>
@@ -3054,13 +3043,15 @@
<td>
{#if s.is_bound}
<span
class="bound-cell"
title={t(
'admin.sessions.bound_tooltip',
{ prefix: s.dpop_jkt_prefix ?? '' },
'DPoP-bound (jkt {{prefix}}…)'
)}
>
🔒 {s.dpop_jkt_prefix ?? ''}
<Icon name="lock" />
<span class="mono">{s.dpop_jkt_prefix ?? ''}</span>
</span>
{:else}
<span class="muted">{t('admin.sessions.unbound', 'unbound')}</span>
@@ -3068,13 +3059,15 @@
</td>
<td>
{#if s.is_revoked}
<span class="badge badge-danger">
<span class="badge badge--inactive">
{t('admin.sessions.revoked', 'revoked')}
</span>
{: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}
<span class="badge badge-ok">
<span class="badge badge--active">
{t('admin.sessions.active', 'active')}
</span>
{/if}
@@ -3082,14 +3075,14 @@
<td>
{#if !s.is_revoked}
<button
class="btn btn-danger btn-sm"
class="icon-btn icon-btn--danger"
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)}
disabled={sessionRevokingId === s.id}
>
{sessionRevokingId === s.id
? t('common.working', 'Working…')
: t('admin.sessions.revoke', 'Revoke')}
<Icon name="trash-alt" />
</button>
{/if}
</td>
@@ -3104,8 +3097,7 @@
{/if}
</tbody>
</table>
</div>
</section>
{/if}
{:else if tab === 'mounts'}
<section class="admin-section" data-testid="admin-mounts-section">
<h2>{t('admin.mounts.title', 'External File Mounts')}</h2>
@@ -4167,10 +4159,10 @@
<style>
/* Admin sessions panel — accent the caller's own row so revoking
it can't happen by muscle memory. Left border + tinted bg pull
the eye; JS confirms with an escalated message on top of that. */
.current-session {
background: var(--color-bg-accent-subtle, color-mix(in srgb, var(--color-accent) 8%, transparent));
it can't happen by muscle memory. Left-border stripe matches how
Users' table calls out the caller via the `you` badge; JS
confirms with an escalated message on top of the visual cue. */
.current-session td:first-child {
border-left: 3px solid var(--color-accent);
}
@@ -5127,6 +5119,60 @@
.bar {
display: flex;
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 {
@@ -5142,6 +5188,18 @@
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 {
display: flex;
flex-direction: column;