Files
publisher-dashboard/apps/publisher-dashboard/src/routes/account/sessions/+page.svelte
igm 665092464a Fix all linter errors
- 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>
2026-01-12 17:30:00 +08:00

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}