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;
}