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