Files
minstrel/web/src/lib/components/ActiveSessions.svelte
T

231 lines
7.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { plural } from '#lib/utils/plural.js';
import { onMount } from 'svelte';
import { MonitorSmartphone, TriangleAlert } from 'lucide-svelte';
import {
listSessions,
revokeSession,
revokeOtherSessions,
type ActiveSession
} from '#lib/api/me.js';
import { errCode } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Self-contained: nothing else in the app reads this data, so it holds its
// own state and reloads explicitly rather than joining the query cache.
let sessions = $state<ActiveSession[] | null>(null);
let loadError = $state(false);
let busy = $state(false);
let confirmingLogoutOthers = $state(false);
const others = $derived((sessions ?? []).filter((s) => !s.current).length);
async function load() {
try {
sessions = await listSessions();
loadError = false;
} catch {
loadError = true;
}
}
onMount(load);
async function onRevoke(s: ActiveSession) {
busy = true;
try {
await revokeSession(s.id);
pushToast('Signed that device out.');
await load();
} catch (e: unknown) {
// Already gone — revoked from another device, or expired. Reloading
// shows the truth, so it isn't worth an error. The code is
// `session_not_found`: apierror.NotFound("session") prefixes it.
if (errCode(e) === 'session_not_found') {
await load();
} else {
pushToast("Couldn't sign that device out.", 'error');
}
} finally {
busy = false;
}
}
async function onLogoutOthers() {
busy = true;
try {
const n = await revokeOtherSessions();
pushToast(`Signed out ${plural(n, 'other device', 'other devices')}.`);
confirmingLogoutOthers = false;
await load();
} catch {
pushToast("Couldn't sign the other devices out.", 'error');
} finally {
busy = false;
}
}
// Deliberately coarse. A full UA parser would be a dependency and a
// maintenance burden for a string whose only job is "do you recognise
// this?" — the IP columns carry the actual signal.
function describeAgent(ua: string): string {
if (!ua) return 'Unknown device';
if (/Minstrel/i.test(ua)) return 'Minstrel for Android';
if (/Android/i.test(ua)) return 'Android browser';
if (/iPhone|iPad|iOS/i.test(ua)) return 'iOS browser';
const browser = /Edg\//.test(ua)
? 'Edge'
: /Firefox\//.test(ua)
? 'Firefox'
: /Chrome\//.test(ua)
? 'Chrome'
: /Safari\//.test(ua)
? 'Safari'
: '';
const os = /Windows/.test(ua)
? 'Windows'
: /Mac OS X/.test(ua)
? 'macOS'
: /Linux/.test(ua)
? 'Linux'
: '';
if (browser && os) return `${browser} on ${os}`;
if (browser) return browser;
return ua.length > 40 ? `${ua.slice(0, 40)}…` : ua;
}
function when(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return 'unknown';
const mins = Math.round((Date.now() - then) / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} min ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${plural(hours, 'hour', 'hours')} ago`;
const days = Math.round(hours / 24);
if (days < 30) return days === 1 ? 'yesterday' : `${days} days ago`;
return new Date(iso).toLocaleDateString();
}
// The reason IP is stored at all. Rather than making someone eyeball two
// addresses per row, say plainly when a session is being used from
// somewhere other than where it was created.
function hasMoved(s: ActiveSession): boolean {
return !!s.created_ip && !!s.last_ip && s.created_ip !== s.last_ip;
}
function addr(ip: string): string {
return ip || 'unknown';
}
</script>
<!-- Settings › Account's last group (#5595), styled as SettingsGroup is. -->
<section class="space-y-3 rounded-lg border border-border bg-surface px-4 pt-3 pb-4">
<header>
<h2 class="text-base font-medium text-text-primary">Signed-in devices</h2>
<p class="text-xs text-text-secondary">
One you don't recognise? Sign it out and change your password.
</p>
</header>
{#if loadError}
<p class="text-sm text-action-destructive">
Couldn't load your sessions.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if sessions === null}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if sessions.length === 0}
<!-- Practically unreachable: listing requires an authenticated request,
which means at least one session exists. Handled rather than assumed. -->
<p class="text-sm text-text-secondary">No active sessions.</p>
{:else}
<ul class="divide-y divide-border">
{#each sessions as s (s.id)}
<li class="flex items-start gap-3 py-3">
<MonitorSmartphone
size={18}
class="mt-0.5 shrink-0 text-text-secondary"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-text-primary">{describeAgent(s.user_agent)}</span>
{#if s.current}
<span class="rounded bg-surface-hover px-1.5 py-0.5 text-xs text-text-secondary">
This device
</span>
{/if}
{#if hasMoved(s)}
<span
class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-action-destructive"
>
<TriangleAlert size={12} aria-hidden="true" />
Address changed
</span>
{/if}
</div>
<div class="text-xs text-text-secondary">
Last seen {when(s.last_seen_at)} from <span class="font-mono">{addr(s.last_ip)}</span>
</div>
<div class="text-xs text-text-secondary">
Signed in {when(s.created_at)} from <span class="font-mono">{addr(s.created_ip)}</span>
</div>
</div>
{#if !s.current}
<button
type="button"
class="shrink-0 rounded border border-border px-2 py-1 text-sm
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
disabled={busy}
onclick={() => onRevoke(s)}
>
Sign out
</button>
{/if}
</li>
{/each}
</ul>
{#if others > 0}
{#if confirmingLogoutOthers}
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-text-secondary">
Sign out {plural(others, 'other device', 'other devices')}? You'll stay
signed in here.
</span>
<button
type="button"
class="rounded bg-action-destructive px-3 py-1 text-sm text-action-fg
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
disabled={busy}
onclick={onLogoutOthers}
>
Sign them out
</button>
<button
type="button"
class="rounded border border-border px-3 py-1 text-sm hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent"
onclick={() => (confirmingLogoutOthers = false)}
>
Cancel
</button>
</div>
{:else}
<button
type="button"
class="rounded border border-border px-3 py-1 text-sm hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
disabled={busy}
onclick={() => (confirmingLogoutOthers = true)}
>
Sign out all other devices
</button>
{/if}
{/if}
{/if}
</section>