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" />
|
<polyline points="9 18 15 12 9 6" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</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
|
<a
|
||||||
href="/settings/api-tokens"
|
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"
|
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