feat(auth): sessions page to list and revoke devices

- /settings/sessions: list user's active sessions with label, IP,
  last-used, expires, user-agent. Highlights the current device.
- Revoke one session (/api/sessions/:id DELETE) or all-other sessions
  (/api/sessions DELETE). Admins can revoke any session.
- Revoking the current session clears cookies and kicks the user to
  /login.
- Wired into the main settings page.
This commit is contained in:
2026-04-16 03:46:33 +03:00
parent b9f3a2ca0b
commit 9cab7262e6
5 changed files with 299 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { success, error } from '$lib/server/utils/response.js';
import * as authService from '$lib/server/services/authService.js';
import { COOKIE_NAMES } from '$lib/server/utils/sessionCookies.js';
/**
* GET /api/sessions — list the current user's active sessions.
*/
export const GET: RequestHandler = async (event) => {
const user = requireAuth(event);
const sessions = await authService.listUserSessions(user.id);
return json(success(sessions));
};
/**
* DELETE /api/sessions — revoke all sessions EXCEPT the current one.
* Useful as a "sign out everywhere else" action.
*/
export const DELETE: RequestHandler = async (event) => {
const user = requireAuth(event);
const currentSessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID) ?? undefined;
try {
await authService.revokeAllUserSessions(user.id, currentSessionId);
return json(success({ revoked: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to revoke sessions';
return json(error(message), { status: 500 });
}
};
+51
View File
@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { success, error } from '$lib/server/utils/response.js';
import { prisma } from '$lib/server/prisma.js';
import * as authService from '$lib/server/services/authService.js';
import {
COOKIE_NAMES,
clearSessionCookies
} from '$lib/server/utils/sessionCookies.js';
/**
* DELETE /api/sessions/:id — revoke a single session.
* Only the owner (or an admin) can revoke it.
* If the caller revokes their own current session, auth cookies are cleared.
*/
export const DELETE: RequestHandler = async (event) => {
const user = requireAuth(event);
const sessionId = event.params.id;
if (!sessionId) {
return json(error('Session id is required'), { status: 400 });
}
const session = await prisma.session.findUnique({
where: { id: sessionId },
select: { id: true, userId: true }
});
if (!session) {
return json(error('Session not found'), { status: 404 });
}
if (session.userId !== user.id && user.role !== 'admin') {
return json(error('Forbidden'), { status: 403 });
}
try {
await authService.revokeSession(sessionId);
const currentSessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID);
if (currentSessionId === sessionId) {
clearSessionCookies(event.cookies);
}
return json(success({ revoked: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to revoke session';
return json(error(message), { status: 500 });
}
};
+19
View File
@@ -38,6 +38,25 @@
<polyline points="9 18 15 12 9 6" />
</svg>
</a>
<a
href="/settings/sessions"
class="flex items-center justify-between rounded-lg border border-border px-4 py-3 text-sm text-foreground transition-colors hover:bg-accent"
>
<div class="flex items-center gap-3">
<svg class="h-5 w-5 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
<div>
<p class="font-medium">Active sessions</p>
<p class="text-xs text-muted-foreground">See where you're signed in and revoke devices</p>
</div>
</div>
<svg class="h-4 w-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</a>
<a
href="/settings/api-tokens"
class="flex items-center justify-between rounded-lg border border-border px-4 py-3 text-sm text-foreground transition-colors hover:bg-accent"
@@ -0,0 +1,15 @@
import type { PageServerLoad } from './$types.js';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import * as authService from '$lib/server/services/authService.js';
import { COOKIE_NAMES } from '$lib/server/utils/sessionCookies.js';
export const load: PageServerLoad = async (event) => {
const user = requireAuth(event);
const sessions = await authService.listUserSessions(user.id);
const currentSessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID) ?? null;
return {
sessions,
currentSessionId
};
};
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
let error = $state<string | null>(null);
let revokingId = $state<string | null>(null);
let revokingAll = $state(false);
function formatDate(d: string | Date): string {
const date = typeof d === 'string' ? new Date(d) : d;
return date.toLocaleString();
}
function relativeTime(d: string | Date): string {
const date = typeof d === 'string' ? new Date(d) : d;
const diff = Date.now() - date.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} min ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} h ago`;
const days = Math.floor(hours / 24);
return `${days} d ago`;
}
async function revokeSession(id: string) {
error = null;
revokingId = id;
try {
const res = await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
const json = await res.json();
if (!json.success) {
error = json.error ?? 'Failed to revoke session';
return;
}
// If they revoked their own session, the API will have cleared cookies;
// the next navigation will redirect to /login via hooks.
if (id === data.currentSessionId) {
window.location.href = '/login';
return;
}
await invalidateAll();
} catch {
error = 'Failed to revoke session';
} finally {
revokingId = null;
}
}
async function revokeAllOthers() {
error = null;
revokingAll = true;
try {
const res = await fetch('/api/sessions', { method: 'DELETE' });
const json = await res.json();
if (!json.success) {
error = json.error ?? 'Failed to revoke sessions';
return;
}
await invalidateAll();
} catch {
error = 'Failed to revoke sessions';
} finally {
revokingAll = false;
}
}
</script>
<svelte:head>
<title>Active sessions | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-3xl space-y-6 px-4 py-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Active sessions</h1>
<p class="mt-1 text-sm text-muted-foreground">
Devices and browsers currently signed in to your account.
</p>
</div>
{#if data.sessions.length > 1}
<button
type="button"
onclick={revokeAllOthers}
disabled={revokingAll}
class="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive hover:bg-destructive/20 disabled:opacity-50"
>
{revokingAll ? 'Revoking…' : 'Sign out all other sessions'}
</button>
{/if}
</div>
{#if error}
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="space-y-3">
{#each data.sessions as session (session.id)}
{@const isCurrent = session.id === data.currentSessionId}
<div
class="rounded-xl border border-border bg-card p-4 {isCurrent
? 'ring-1 ring-primary/40'
: ''}"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate font-medium text-card-foreground">
{session.label ?? 'Unknown device'}
</p>
{#if isCurrent}
<span
class="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary"
>
This device
</span>
{/if}
{#if session.rememberMe}
<span
class="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
Remember me
</span>
{/if}
</div>
<dl class="mt-2 space-y-1 text-xs text-muted-foreground">
{#if session.ipAddress}
<div class="flex gap-2">
<dt class="w-20 shrink-0">IP</dt>
<dd class="truncate font-mono">{session.ipAddress}</dd>
</div>
{/if}
<div class="flex gap-2">
<dt class="w-20 shrink-0">Last active</dt>
<dd>{relativeTime(session.lastUsedAt)} · {formatDate(session.lastUsedAt)}</dd>
</div>
<div class="flex gap-2">
<dt class="w-20 shrink-0">Signed in</dt>
<dd>{formatDate(session.createdAt)}</dd>
</div>
<div class="flex gap-2">
<dt class="w-20 shrink-0">Expires</dt>
<dd>{formatDate(session.expiresAt)}</dd>
</div>
{#if session.userAgent}
<div class="flex gap-2">
<dt class="w-20 shrink-0">User agent</dt>
<dd class="truncate font-mono">{session.userAgent}</dd>
</div>
{/if}
</dl>
</div>
<button
type="button"
onclick={() => revokeSession(session.id)}
disabled={revokingId === session.id}
class="shrink-0 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50 disabled:opacity-50"
>
{#if revokingId === session.id}
Revoking…
{:else if isCurrent}
Sign out
{:else}
Revoke
{/if}
</button>
</div>
</div>
{/each}
</div>
{#if data.sessions.length === 0}
<div class="rounded-xl border border-border bg-card p-6 text-center text-sm text-muted-foreground">
No active sessions.
</div>
{/if}
</div>