- Remove unused biome suppression comment in completions.ts - Remove unnecessary if condition in execute-bootstrap.test.ts - Add eslint-disable comments for any type assertions in client.test.ts - Add eslint-disable comments for expect().rejects patterns - Fix template literal number expression with toString() - Fix error handling in test-db.ts to avoid object stringify Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
284 lines
9.2 KiB
Svelte
284 lines
9.2 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
AlertCircle,
|
|
Key,
|
|
Loader2,
|
|
LogOut,
|
|
MapPin,
|
|
Monitor,
|
|
Smartphone,
|
|
Star,
|
|
Tablet,
|
|
} from "@lucide/svelte";
|
|
import { formatDate, formatRelativeTime } from "@reviq/common";
|
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
|
import { toast } from "svelte-sonner";
|
|
import { UAParser } from "ua-parser-js";
|
|
import { api } from "$lib/api/client";
|
|
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
|
import { Badge } from "$lib/components/ui/badge";
|
|
import { Button } from "$lib/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "$lib/components/ui/card";
|
|
import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const sessionsQuery = createQuery(() => ({
|
|
queryKey: ["sessions"],
|
|
queryFn: () => api.me.sessions.list(),
|
|
}));
|
|
|
|
let confirmDialogOpen = $state(false);
|
|
let confirmAllDialogOpen = $state(false);
|
|
let selectedSessionId = $state<number | null>(null);
|
|
let isRevoking = $state(false);
|
|
let isRevokingAll = $state(false);
|
|
|
|
// Split sessions into active and past
|
|
const activeSessions = $derived(
|
|
sessionsQuery.data?.filter((s) => s.revokedAt === null) ?? [],
|
|
);
|
|
const pastSessions = $derived(
|
|
sessionsQuery.data?.filter((s) => s.revokedAt !== null) ?? [],
|
|
);
|
|
|
|
function formatLocation(session: {
|
|
city: string | null;
|
|
region: string | null;
|
|
country: string | null;
|
|
}): string {
|
|
const parts = [session.city, session.region, session.country].filter(Boolean);
|
|
return parts.length > 0 ? parts.join(", ") : "Unknown location";
|
|
}
|
|
|
|
function parseUserAgent(userAgent: string): {
|
|
browser: string;
|
|
os: string;
|
|
deviceType: string;
|
|
} {
|
|
const parser = new UAParser(userAgent);
|
|
const browser = parser.getBrowser().name || "Unknown browser";
|
|
const os = parser.getOS().name || "Unknown OS";
|
|
const deviceType = parser.getDevice().type || "desktop";
|
|
return { browser, os, deviceType };
|
|
}
|
|
|
|
function getDeviceIcon(deviceType: string) {
|
|
switch (deviceType) {
|
|
case "mobile":
|
|
return Smartphone;
|
|
case "tablet":
|
|
return Tablet;
|
|
default:
|
|
return Monitor;
|
|
}
|
|
}
|
|
|
|
function openRevokeDialog(sessionId: number) {
|
|
selectedSessionId = sessionId;
|
|
confirmDialogOpen = true;
|
|
}
|
|
|
|
async function handleRevoke() {
|
|
if (!selectedSessionId || isRevoking) {
|
|
return;
|
|
}
|
|
|
|
isRevoking = true;
|
|
try {
|
|
await api.me.sessions.revoke({ sessionId: selectedSessionId });
|
|
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
|
toast.success("Session revoked");
|
|
confirmDialogOpen = false;
|
|
selectedSessionId = null;
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to revoke session");
|
|
} finally {
|
|
isRevoking = false;
|
|
}
|
|
}
|
|
|
|
async function handleRevokeAll() {
|
|
if (isRevokingAll) {
|
|
return;
|
|
}
|
|
|
|
isRevokingAll = true;
|
|
try {
|
|
await api.me.sessions.revokeAll();
|
|
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
|
toast.success("All other sessions revoked");
|
|
confirmAllDialogOpen = false;
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to revoke sessions");
|
|
} finally {
|
|
isRevokingAll = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if sessionsQuery.isLoading}
|
|
<div class="flex items-center justify-center py-12">
|
|
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
{:else if sessionsQuery.error}
|
|
<Alert variant="destructive">
|
|
<AlertCircle class="h-4 w-4" />
|
|
<AlertDescription>Failed to load sessions. Please try again.</AlertDescription>
|
|
</Alert>
|
|
{:else}
|
|
<div class="space-y-6">
|
|
<!-- Active Sessions -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Active Sessions</CardTitle>
|
|
<CardDescription>
|
|
Devices that are currently signed in to your account.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{#if activeSessions.length > 0}
|
|
<div class="divide-y">
|
|
{#each activeSessions as session (session.id)}
|
|
{@const { browser, os, deviceType } = parseUserAgent(session.userAgent)}
|
|
{@const DeviceIcon = getDeviceIcon(deviceType)}
|
|
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
|
<DeviceIcon class="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<p class="text-sm font-medium">{browser} on {os}</p>
|
|
{#if session.isCurrent}
|
|
<Badge variant="outline" class="text-xs">
|
|
<Star class="mr-1 h-3 w-3" />
|
|
Current
|
|
</Badge>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<div class="flex items-center gap-1">
|
|
<MapPin class="h-3 w-3" />
|
|
<span>{formatLocation(session)}</span>
|
|
</div>
|
|
<span>·</span>
|
|
{#if session.trustedMode}
|
|
<div class="flex items-center gap-1">
|
|
<Key class="h-3 w-3" />
|
|
<span>via passkey</span>
|
|
</div>
|
|
{:else}
|
|
<span>via password</span>
|
|
{/if}
|
|
</div>
|
|
<p class="text-xs text-muted-foreground">
|
|
Started {formatRelativeTime(session.createdAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{#if !session.isCurrent}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={() => openRevokeDialog(session.id)}
|
|
>
|
|
Revoke
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if activeSessions.length > 1}
|
|
<div class="mt-4 border-t pt-4">
|
|
<Button
|
|
variant="outline"
|
|
class="w-full text-destructive hover:text-destructive"
|
|
onclick={() => (confirmAllDialogOpen = true)}
|
|
>
|
|
Revoke all other sessions
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<div class="flex flex-col items-center justify-center py-8 text-center">
|
|
<Monitor class="mb-2 h-8 w-8 text-muted-foreground/50" />
|
|
<p class="text-sm text-muted-foreground">No active sessions.</p>
|
|
</div>
|
|
{/if}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Past Sessions -->
|
|
{#if pastSessions.length > 0}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Past Sessions</CardTitle>
|
|
<CardDescription>
|
|
Sessions that have been logged out or revoked.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div class="divide-y">
|
|
{#each pastSessions.slice(0, 10) as session (session.id)}
|
|
{@const { browser, os, deviceType } = parseUserAgent(session.userAgent)}
|
|
{@const DeviceIcon = getDeviceIcon(deviceType)}
|
|
<div class="flex items-center gap-3 py-3 first:pt-0 last:pb-0 opacity-60">
|
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
|
<DeviceIcon class="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium">{browser} on {os}</p>
|
|
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<MapPin class="h-3 w-3" />
|
|
<span>{formatLocation(session)}</span>
|
|
</div>
|
|
<p class="text-xs text-muted-foreground">
|
|
{formatDate(session.createdAt)} - {session.revokedAt ? formatDate(session.revokedAt) : ""}
|
|
</p>
|
|
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<LogOut class="h-3 w-3" />
|
|
<span>Logged out</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{#if pastSessions.length > 10}
|
|
<p class="mt-4 text-center text-xs text-muted-foreground">
|
|
Showing 10 of {pastSessions.length} past sessions
|
|
</p>
|
|
{/if}
|
|
</CardContent>
|
|
</Card>
|
|
{/if}
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
bind:open={confirmDialogOpen}
|
|
title="Revoke this session?"
|
|
description="This will sign out the device. You'll need to sign in again to use that device."
|
|
confirmText="Revoke session"
|
|
variant="destructive"
|
|
loading={isRevoking}
|
|
onConfirm={handleRevoke}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
bind:open={confirmAllDialogOpen}
|
|
title="Revoke all other sessions?"
|
|
description="This will sign out all devices except your current one. You'll need to sign in again on those devices."
|
|
confirmText="Revoke all"
|
|
variant="destructive"
|
|
loading={isRevokingAll}
|
|
onConfirm={handleRevokeAll}
|
|
/>
|
|
{/if}
|