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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user