From 9cab7262e6577b16686616166670bb696bd3b302 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 16 Apr 2026 03:46:33 +0300 Subject: [PATCH] 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. --- src/routes/api/sessions/+server.ts | 32 ++++ src/routes/api/sessions/[id]/+server.ts | 51 ++++++ src/routes/settings/+page.svelte | 19 ++ src/routes/settings/sessions/+page.server.ts | 15 ++ src/routes/settings/sessions/+page.svelte | 182 +++++++++++++++++++ 5 files changed, 299 insertions(+) create mode 100644 src/routes/api/sessions/+server.ts create mode 100644 src/routes/api/sessions/[id]/+server.ts create mode 100644 src/routes/settings/sessions/+page.server.ts create mode 100644 src/routes/settings/sessions/+page.svelte diff --git a/src/routes/api/sessions/+server.ts b/src/routes/api/sessions/+server.ts new file mode 100644 index 0000000..7a1249e --- /dev/null +++ b/src/routes/api/sessions/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/sessions/[id]/+server.ts b/src/routes/api/sessions/[id]/+server.ts new file mode 100644 index 0000000..ba5b8d4 --- /dev/null +++ b/src/routes/api/sessions/[id]/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 003edef..c6bb23f 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -38,6 +38,25 @@ + +
+ + + + + +
+

Active sessions

+

See where you're signed in and revoke devices

+
+
+ + + +
{ + const user = requireAuth(event); + const sessions = await authService.listUserSessions(user.id); + const currentSessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID) ?? null; + + return { + sessions, + currentSessionId + }; +}; diff --git a/src/routes/settings/sessions/+page.svelte b/src/routes/settings/sessions/+page.svelte new file mode 100644 index 0000000..2457668 --- /dev/null +++ b/src/routes/settings/sessions/+page.svelte @@ -0,0 +1,182 @@ + + + + Active sessions | {$t('app_name')} + + +
+
+
+

Active sessions

+

+ Devices and browsers currently signed in to your account. +

+
+ {#if data.sessions.length > 1} + + {/if} +
+ + {#if error} +
+ {error} +
+ {/if} + +
+ {#each data.sessions as session (session.id)} + {@const isCurrent = session.id === data.currentSessionId} +
+
+
+
+

+ {session.label ?? 'Unknown device'} +

+ {#if isCurrent} + + This device + + {/if} + {#if session.rememberMe} + + Remember me + + {/if} +
+
+ {#if session.ipAddress} +
+
IP
+
{session.ipAddress}
+
+ {/if} +
+
Last active
+
{relativeTime(session.lastUsedAt)} · {formatDate(session.lastUsedAt)}
+
+
+
Signed in
+
{formatDate(session.createdAt)}
+
+
+
Expires
+
{formatDate(session.expiresAt)}
+
+ {#if session.userAgent} +
+
User agent
+
{session.userAgent}
+
+ {/if} +
+
+ +
+
+ {/each} +
+ + {#if data.sessions.length === 0} +
+ No active sessions. +
+ {/if} +