feat(session): handle sessions for admin
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user