feat(session): handle sessions for admin

This commit is contained in:
Edouard Vanbelle
2026-08-09 03:39:19 +02:00
parent 1b9d812175
commit bee856fbd0
17 changed files with 997 additions and 35 deletions
+34
View File
@@ -6,6 +6,7 @@
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type {
AdminSessionsPage,
AdminUsersPage,
Drive,
DriveMember,
@@ -241,6 +242,39 @@ export async function deleteDriveAdmin(driveId: string): Promise<void> {
}
}
// ── Sessions (admin panel) ──────────────────────────────────────────────
/** Options for {@link listAdminSessions}. */
export interface ListSessionsOpts {
/** Narrow to one user's sessions; omit for cross-user listing. */
userId?: string;
/** Include revoked / expired rows. Default `false` (active-only UX). */
includeRevoked?: boolean;
/** Page size — server caps at 500. */
limit?: number;
/** Pagination offset. */
offset?: number;
}
/** Global sessions listing — `GET /api/admin/sessions`. */
export function listAdminSessions(opts: ListSessionsOpts = {}): Promise<AdminSessionsPage> {
const params = new URLSearchParams();
if (opts.userId) params.set('user_id', opts.userId);
if (opts.includeRevoked) params.set('include_revoked', 'true');
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
const qs = params.toString();
return apiJson<AdminSessionsPage>(`/api/admin/sessions${qs ? '?' + qs : ''}`, {
credentials: 'same-origin'
});
}
/** Revoke a session — `DELETE /api/admin/sessions/{id}`. Sets
* `revoked=true`; the row stays in the DB for audit visibility. */
export function revokeAdminSession(sessionId: string): Promise<void> {
return mutate(`/api/admin/sessions/${encodeURIComponent(sessionId)}`, 'DELETE');
}
// ── Users ───────────────────────────────────────────────────────────────
/** List the compact rows rendered by the management table; full account
+28
View File
@@ -734,3 +734,31 @@ export interface Finding {
detail: Record<string, unknown>;
created_at: string;
}
/**
* Admin sessions-panel row shape. Backend: `SessionSummaryDto` in
* `src/application/dtos/session_dto.rs`. Deliberately narrower than
* the DB row — the refresh token is never serialised, and the full
* DPoP thumbprint is truncated to an 8-char prefix so admins viewing
* other users' sessions can't exfiltrate the full binding fingerprint.
*/
export interface SessionSummary {
id: string;
user_id: string;
created_at: string;
expires_at: string;
ip_address: string | null;
user_agent: string | null;
is_bound: boolean;
dpop_jkt_prefix: string | null;
is_revoked: boolean;
is_active: boolean;
oidc_sid: string | null;
}
/** Wire response of `GET /api/admin/sessions`. */
export interface AdminSessionsPage {
sessions: SessionSummary[];
limit: number;
offset: number;
}
@@ -86,6 +86,12 @@
icon: 'users',
section: 'admin-users'
},
{
href: '/admin/sessions',
label: t('admin.sessions', 'Sessions'),
icon: 'key',
section: 'admin-sessions'
},
{
href: '/admin/drives',
label: t('admin.drives', 'Drives'),
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { shortUserAgent } from './userAgent';
describe('shortUserAgent', () => {
it('placeholder for missing input', () => {
expect(shortUserAgent(null)).toBe('—');
expect(shortUserAgent(undefined)).toBe('—');
expect(shortUserAgent('')).toBe('—');
});
it('device-auth marker passes through unchanged', () => {
expect(shortUserAgent('device:my-tv-42')).toBe('device:my-tv-42');
});
it.each([
[
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
'Chrome on Mac'
],
[
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36',
'Chrome on Windows'
],
[
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36',
'Chrome on Linux'
],
[
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0',
'Firefox on Windows'
],
[
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0',
'Firefox on Linux'
],
[
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
'Safari on Mac'
],
[
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
'Safari on iOS'
],
[
// iPad on iPadOS 13+ ships a UA with "Macintosh" — must NOT
// mis-detect as Mac. Guarded by iOS-first ordering.
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
'Safari on iOS'
],
[
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36',
'Chrome on Android'
],
[
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51',
'Edge on Windows'
],
[
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 OPR/110.0.0.0',
'Opera on Linux'
],
['curl/8.7.1', 'curl'],
['Wget/1.21.4', 'wget'],
['Mozilla/5.0 (Nextcloud desktop client 3.14.2 stable-x86_64)', 'Nextcloud client'],
['node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 'Node']
])('%s → %s', (ua, expected) => {
expect(shortUserAgent(ua)).toBe(expected);
});
it('truncates unknown UA shapes past 40 chars', () => {
const long = 'SomeWeirdCrawler/1.0 with a very long description of its capabilities';
const result = shortUserAgent(long);
expect(result.endsWith('…')).toBe(true);
expect(result.length).toBeLessThanOrEqual(41);
});
it('returns short unknown UA verbatim', () => {
expect(shortUserAgent('MyBot/1.0')).toBe('MyBot/1.0');
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Parse a raw HTTP `User-Agent` string into a compact human label like
* "Chrome on Mac" for admin/session UIs. Not a general-purpose UA
* parser — pragmatic regex-based buckets covering the browsers +
* operating systems that make up ~99% of real-world traffic, plus the
* OxiCloud-specific device-auth prefix.
*
* Order of detection matters:
* * Edge / Opera / Firefox before Chrome (they all include `Chrome/…`)
* * Chrome before Safari (Chrome includes `Safari/…`)
* * Version check guards Safari against matching a WebKit-based crawler
*
* `null` / `undefined` / empty → `"—"` so the admin table renders a
* consistent placeholder without every callsite writing `?? '—'`.
*/
export function shortUserAgent(ua: string | null | undefined): string {
if (!ua) return '—';
// Device-authorization grant sessions carry a bespoke marker
// (`device:<client_name>`) instead of a browser UA. Pass through.
if (ua.startsWith('device:')) return ua;
// Browser detection — order matters.
let browser: string | null = null;
if (/\bEdg[eA]?\//.test(ua)) browser = 'Edge';
else if (/\bOPR\/|Opera\//.test(ua)) browser = 'Opera';
else if (/\bFirefox\/|FxiOS\//.test(ua)) browser = 'Firefox';
else if (/\bChrome\//.test(ua)) browser = 'Chrome';
else if (/\bSafari\//.test(ua) && /\bVersion\//.test(ua)) browser = 'Safari';
else if (/\bcurl\//.test(ua)) browser = 'curl';
else if (/\bwget/i.test(ua)) browser = 'wget';
else if (/\bNextcloud\b/i.test(ua)) browser = 'Nextcloud client';
else if (/\bnode\b/i.test(ua)) browser = 'Node';
// OS detection — iOS/iPad before Mac (iPad UAs include "Macintosh" on
// modern iPadOS "desktop mode"; without the iPad check first they'd
// be miscategorised as Mac).
let os: string | null = null;
if (/Windows/i.test(ua)) os = 'Windows';
else if (/iPhone|iPad|iPod/i.test(ua)) os = 'iOS';
else if (/Android/i.test(ua)) os = 'Android';
else if (/Mac OS X|Macintosh/i.test(ua)) os = 'Mac';
else if (/CrOS/i.test(ua)) os = 'ChromeOS';
else if (/Linux/i.test(ua)) os = 'Linux';
else if (/FreeBSD|OpenBSD|NetBSD/i.test(ua)) os = 'BSD';
if (browser && os) return `${browser} on ${os}`;
if (browser) return browser;
if (os) return os;
// Unknown shape — truncate the raw string so a huge UA doesn't
// blow up the table column width. Full string still available in
// the row's `title=` tooltip.
return ua.length > 40 ? ua.slice(0, 40) + '…' : ua;
}
@@ -18,6 +18,8 @@
installPlugin,
listPlugins,
listUsers,
listAdminSessions,
revokeAdminSession,
getUserAdmin,
migrationAction,
reextractAudioMetadata,
@@ -73,8 +75,10 @@
DriveMember,
DrivePolicies,
DrivePoliciesPartial,
SessionSummary,
User
} from '$lib/api/types';
import { shortUserAgent } from '$lib/utils/userAgent';
import { triggerJob } from '$lib/api/endpoints/adminJobs';
import { serverStatus } from '$lib/stores/serverStatus.svelte';
import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte';
@@ -177,6 +181,7 @@
type Tab =
| 'dashboard'
| 'users'
| 'sessions'
| 'drives'
| 'mounts'
| 'plugins'
@@ -188,6 +193,7 @@
const VALID_TABS: readonly Tab[] = [
'dashboard',
'users',
'sessions',
'drives',
'mounts',
'plugins',
@@ -221,6 +227,8 @@
return t('admin.dashboard', 'Dashboard');
case 'users':
return t('admin.users', 'Users');
case 'sessions':
return t('admin.sessions', 'Sessions');
case 'drives':
return t('admin.drives', 'Drives');
case 'mounts':
@@ -842,6 +850,56 @@
let resetError = $state<string | null>(null);
let resetting = $state(false);
// Sessions (admin panel — see task #52 / docs/plan/dpop.md Gate 10).
// Global cross-user listing by default; user-filter dropdown narrows.
// Active-only by default (hides revoked + expired); checkbox opts into
// showing everything for forensics. Revoke action mutates in place —
// the row is refetched to update `is_revoked` badge.
let sessions = $state<SessionSummary[]>([]);
let sessionsError = $state<string | null>(null);
let sessionsLoading = $state(false);
let sessionsFilterUserId = $state<string>('');
let sessionsIncludeRevoked = $state(false);
let sessionRevokingId = $state<string | null>(null);
async function loadSessions() {
sessionsLoading = true;
sessionsError = null;
try {
const page = await listAdminSessions({
userId: sessionsFilterUserId || undefined,
includeRevoked: sessionsIncludeRevoked,
limit: PAGE_SIZE
});
sessions = page.sessions;
} catch (e) {
sessionsError = errorMessage(e);
} finally {
sessionsLoading = false;
}
}
async function onRevokeSession(id: string) {
if (
!confirm(
t(
'admin.sessions.revoke_confirm',
'Revoke this session? The next request from that browser will 401.'
)
)
)
return;
sessionRevokingId = id;
try {
await revokeAdminSession(id);
await loadSessions();
} catch (e) {
sessionsError = errorMessage(e);
} finally {
sessionRevokingId = null;
}
}
// Plugins
let plugins = $state<PluginInfo[]>([]);
let pluginsAvailable = $state(true);
@@ -1581,6 +1639,7 @@
let loaded = $state<Record<Tab, boolean>>({
dashboard: false,
users: false,
sessions: false,
drives: false,
mounts: false,
plugins: false,
@@ -1595,6 +1654,7 @@
loaded[tab] = true;
if (tab === 'dashboard') void loadDashboard();
else if (tab === 'users') void loadUsers();
else if (tab === 'sessions') void loadSessions();
else if (tab === 'drives') void loadDrivesTab();
else if (tab === 'mounts') void loadMounts();
else if (tab === 'plugins') void loadPlugins();
@@ -2898,6 +2958,135 @@
>
</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)')}:
<input
class="input"
type="text"
placeholder="00000000-…"
data-testid="admin-sessions-user-filter-input"
bind:value={sessionsFilterUserId}
/>
</label>
<label>
<input
type="checkbox"
data-testid="admin-sessions-include-revoked-checkbox"
bind:checked={sessionsIncludeRevoked}
/>
{t('admin.sessions.include_revoked', 'Include revoked / expired')}
</label>
<button
class="btn"
data-testid="admin-sessions-refresh-btn"
onclick={() => void loadSessions()}
disabled={sessionsLoading}
>
{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">
<thead>
<tr>
<th>{t('admin.sessions.col_user', 'User')}</th>
<th>{t('admin.sessions.col_created', 'Created')}</th>
<th>{t('admin.sessions.col_expires', 'Expires')}</th>
<th>{t('admin.sessions.col_ip', 'IP')}</th>
<th>{t('admin.sessions.col_user_agent', 'User agent')}</th>
<th>{t('admin.sessions.col_bound', 'Bound')}</th>
<th>{t('admin.sessions.col_status', 'Status')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each sessions as s (s.id)}
<tr
data-testid={`admin-sessions-row-${s.id}`}
class:muted={!s.is_active}
>
<td class="mono" title={s.user_id}>{s.user_id.slice(0, 8)}…</td>
<td>{new Date(s.created_at).toLocaleString()}</td>
<td>{new Date(s.expires_at).toLocaleString()}</td>
<td class="mono">{s.ip_address ?? '—'}</td>
<td class="truncate" title={s.user_agent ?? ''}>
{shortUserAgent(s.user_agent)}
</td>
<td>
{#if s.is_bound}
<span
title={t(
'admin.sessions.bound_tooltip',
{ prefix: s.dpop_jkt_prefix ?? '' },
'DPoP-bound (jkt {{prefix}}…)'
)}
>
🔒 {s.dpop_jkt_prefix ?? ''}
</span>
{:else}
<span class="muted">{t('admin.sessions.unbound', 'unbound')}</span>
{/if}
</td>
<td>
{#if s.is_revoked}
<span class="badge badge-danger">
{t('admin.sessions.revoked', 'revoked')}
</span>
{:else if !s.is_active}
<span class="badge">{t('admin.sessions.expired', 'expired')}</span>
{:else}
<span class="badge badge-ok">
{t('admin.sessions.active', 'active')}
</span>
{/if}
</td>
<td>
{#if !s.is_revoked}
<button
class="btn btn-danger btn-sm"
data-testid={`admin-sessions-revoke-btn-${s.id}`}
onclick={() => void onRevokeSession(s.id)}
disabled={sessionRevokingId === s.id}
>
{sessionRevokingId === s.id
? t('common.working', 'Working…')
: t('admin.sessions.revoke', 'Revoke')}
</button>
{/if}
</td>
</tr>
{/each}
{#if sessions.length === 0 && !sessionsLoading}
<tr>
<td colspan="8" class="muted">
{t('admin.sessions.empty', 'No sessions match the current filter.')}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</section>
{:else if tab === 'mounts'}
<section class="admin-section" data-testid="admin-mounts-section">
<h2>{t('admin.mounts.title', 'External File Mounts')}</h2>