diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 14c73d38..241bfec7 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -171,6 +171,19 @@ export async function bindDpopIfPossible(): Promise { 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); diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index da6107d0..c0fcca6f 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -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' }, { diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 6e09388d..bb58f859 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -70,6 +70,17 @@ export const OxiIcons: Record = { 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" diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 5621f386..c924b310 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -2677,14 +2677,15 @@ {@const pct = quotaPct(u)} -
- - {u.username || u.email} - {#if isSelf(u)} - {t('admin.you_badge', 'you')} - {/if} - - {u.email} +
+ + {#if isSelf(u)} + {t('admin.you_badge', 'you')} + {/if}
@@ -2964,148 +2965,139 @@
{/if} {:else if tab === 'sessions'} -
-

{t('admin.sessions.title', 'Sessions')}

-

- {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.' - )} -

- -
- - - -
- - {#if sessionsError} -
- {sessionsError} -
- {/if} - -
- - - - - - - - - - - - - - - {#each sessions as s (s.id)} - - + + + + + + + + + {/each} + {#if sessions.length === 0 && !sessionsLoading} + + + + {/if} + +
{t('admin.sessions.col_user', 'User')}{t('admin.sessions.col_created', 'Created')}{t('admin.sessions.col_expires', 'Expires')}{t('admin.sessions.col_ip', 'IP')}{t('admin.sessions.col_user_agent', 'User agent')}{t('admin.sessions.col_bound', 'Bound')}{t('admin.sessions.col_status', 'Status')}
- {s.user_id.slice(0, 8)}… +
+ + + +
+ {#if sessionsError} +

{sessionsError}

+ {:else} + + + + + + + + + + + + + + + {#each sessions as s (s.id)} + + - - - - - - - - - {/each} - {#if sessions.length === 0 && !sessionsLoading} - - - - {/if} - -
{t('admin.sessions.col_user', 'User')}{t('admin.sessions.col_created', 'Created')}{t('admin.sessions.col_expires', 'Expires')}{t('admin.sessions.col_ip', 'IP')}{t('admin.sessions.col_user_agent', 'User agent')}{t('admin.sessions.col_bound', 'Bound')}{t('admin.sessions.col_status', 'Status')}
+
+ {#if s.is_current} - {t('admin.sessions.current', 'you')} + {t('admin.you_badge', 'you')} {/if} -
{new Date(s.created_at).toLocaleString()}{new Date(s.expires_at).toLocaleString()}{s.ip_address ?? '—'} - {shortUserAgent(s.user_agent)} - - {#if s.is_bound} - - 🔒 {s.dpop_jkt_prefix ?? ''} - - {:else} - {t('admin.sessions.unbound', 'unbound')} - {/if} - - {#if s.is_revoked} - - {t('admin.sessions.revoked', 'revoked')} - - {:else if !s.is_active} - {t('admin.sessions.expired', 'expired')} - {:else} - - {t('admin.sessions.active', 'active')} - - {/if} - - {#if !s.is_revoked} - - {/if} -
- {t('admin.sessions.empty', 'No sessions match the current filter.')} -
- - + +
{new Date(s.created_at).toLocaleString()}{new Date(s.expires_at).toLocaleString()}{s.ip_address ?? '—'} + {shortUserAgent(s.user_agent)} + + {#if s.is_bound} + + + {s.dpop_jkt_prefix ?? ''} + + {:else} + {t('admin.sessions.unbound', 'unbound')} + {/if} + + {#if s.is_revoked} + + {t('admin.sessions.revoked', 'revoked')} + + {:else if !s.is_active} + + {t('admin.sessions.expired', 'expired')} + + {:else} + + {t('admin.sessions.active', 'active')} + + {/if} + + {#if !s.is_revoked} + + {/if} +
+ {t('admin.sessions.empty', 'No sessions match the current filter.')} +
+ {/if} {:else if tab === 'mounts'}

{t('admin.mounts.title', 'External File Mounts')}

@@ -4167,10 +4159,10 @@