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() },
|
||||
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);
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user