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() }, 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);
+2 -2
View File
@@ -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'
}, },
{ {
+11
View File
@@ -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"
+103 -45
View File
@@ -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;