Files
libnovel/ui/src/routes/profile/+page.svelte
Admin 1eb70e9b9b
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
Deploy / Cleanup Preview (push) Has been skipped
CI / UI / Build (pull_request) Failing after 13s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 12s
Add session management: track active sessions, show on profile, allow revocation
- Add user_sessions PocketBase collection (user_id, session_id, user_agent, ip, created/last_seen)
- Extend auth token format to include a per-login authSessionId (4th segment)
- Hook validates authSessionId against DB on each request; revoked sessions are cleared immediately
- Login/register create a session record capturing user-agent and IP
- Profile page shows all active sessions with current session highlighted; per-session End/Sign out buttons
- GET /api/sessions and DELETE /api/sessions/[id] endpoints for client-side revocation
- Backward compatible: legacy 3-segment tokens pass through without DB check
2026-03-07 11:53:16 +05:00

344 lines
12 KiB
Svelte

<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
// ── Settings ────────────────────────────────────────────────────────────────
let voices = $state<string[]>([]);
let voicesLoaded = $state(false);
// Load voices on mount
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: string[] }) => {
voices = d.voices ?? [];
voicesLoaded = true;
})
.catch(() => {
voicesLoaded = true;
});
});
// Mirror from audioStore so sliders feel live
let voice = $state(audioStore.voice);
let speed = $state(audioStore.speed);
let autoNext = $state(audioStore.autoNext);
// Keep in sync when layout changes them externally
$effect(() => {
voice = audioStore.voice;
speed = audioStore.speed;
autoNext = audioStore.autoNext;
});
let settingsSaving = $state(false);
let settingsSaved = $state(false);
async function saveSettings() {
settingsSaving = true;
settingsSaved = false;
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed })
});
// Sync to audioStore so the player picks up changes immediately
audioStore.autoNext = autoNext;
audioStore.voice = voice;
audioStore.speed = speed;
await invalidateAll();
settingsSaved = true;
setTimeout(() => (settingsSaved = false), 2500);
} finally {
settingsSaving = false;
}
}
// ── Password change ─────────────────────────────────────────────────────────
let pwSubmitting = $state(false);
let pwSuccess = $state(false);
$effect(() => {
if (form?.success) {
pwSuccess = true;
setTimeout(() => (pwSuccess = false), 3000);
}
});
// ── Sessions ────────────────────────────────────────────────────────────────
type Session = {
id: string;
user_agent: string;
ip: string;
created_at: string;
last_seen: string;
is_current: boolean;
};
let sessions = $state<Session[]>(data.sessions ?? []);
let revokingId = $state<string | null>(null);
let revokeError = $state('');
async function revokeSession(session: Session) {
revokingId = session.id;
revokeError = '';
try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
if (!res.ok) {
revokeError = 'Failed to end session. Please try again.';
return;
}
if (session.is_current) {
// Ended our own session — submit the logout form to clear the cookie
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) {
logoutForm.submit();
}
return;
}
// Remove from local list
sessions = sessions.filter((s) => s.id !== session.id);
} catch {
revokeError = 'Network error. Please try again.';
} finally {
revokingId = null;
}
}
function formatDate(iso: string): string {
if (!iso) return '—';
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(iso));
} catch {
return iso;
}
}
function parseUA(ua: string): string {
if (!ua) return 'Unknown browser';
// Very lightweight UA display — just show the most meaningful part
if (/Mobile/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/);
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
}
if (/Chrome\/(\d+)/i.test(ua)) return `Chrome ${ua.match(/Chrome\/(\d+)/i)![1]}`;
if (/Firefox\/(\d+)/i.test(ua)) return `Firefox ${ua.match(/Firefox\/(\d+)/i)![1]}`;
if (/Safari\/(\d+)/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`;
return ua.slice(0, 48) + (ua.length > 48 ? '…' : '');
}
</script>
<svelte:head>
<title>Profile — libnovel</title>
</svelte:head>
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-xl mx-auto space-y-10">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
<p class="text-zinc-400 text-sm mt-1">Signed in as <span class="text-zinc-200 font-medium">{data.user.username}</span></p>
</div>
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-5">
<h2 class="text-lg font-semibold text-zinc-100">Reading settings</h2>
<!-- Voice -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="voice-select">TTS voice</label>
{#if !voicesLoaded}
<div class="h-9 bg-zinc-700 rounded animate-pulse"></div>
{:else if voices.length === 0}
<select id="voice-select" disabled class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-400 text-sm cursor-not-allowed">
<option>No voices available</option>
</select>
{:else}
<select
id="voice-select"
bind:value={voice}
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
>
{#each voices as v}
<option value={v}>{v}</option>
{/each}
</select>
{/if}
</div>
<!-- Speed -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="speed-range">
Playback speed — <span class="text-amber-400 font-mono">{speed.toFixed(1)}x</span>
</label>
<input
id="speed-range"
type="range"
min="0.5"
max="3.0"
step="0.1"
bind:value={speed}
class="w-full accent-amber-400"
/>
<div class="flex justify-between text-xs text-zinc-500">
<span>0.5x</span>
<span>3.0x</span>
</div>
</div>
<!-- Auto-next -->
<label class="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
bind:checked={autoNext}
class="w-4 h-4 rounded accent-amber-400"
/>
<span class="text-sm text-zinc-300">Auto-advance to next chapter</span>
</label>
<div class="flex items-center gap-3 pt-1">
<button
onclick={saveSettings}
disabled={settingsSaving}
class="px-4 py-2 rounded-lg bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors disabled:opacity-60"
>
{settingsSaving ? 'Saving…' : 'Save settings'}
</button>
{#if settingsSaved}
<span class="text-sm text-green-400">Saved!</span>
{/if}
</div>
</section>
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
<h2 class="text-lg font-semibold text-zinc-100">Active sessions</h2>
<p class="text-sm text-zinc-400">These are all devices currently signed into your account. End any session you don't recognise.</p>
{#if revokeError}
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
{revokeError}
</div>
{/if}
{#if sessions.length === 0}
<p class="text-sm text-zinc-500 italic">No session records found. Sessions are tracked from the next login.</p>
{:else}
<ul class="space-y-2">
{#each sessions as session (session.id)}
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-amber-400/10 border border-amber-400/30' : 'bg-zinc-700/50 border border-zinc-600/50'}">
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-zinc-100 truncate">{parseUA(session.user_agent)}</span>
{#if session.is_current}
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-amber-400/20 text-amber-300 border border-amber-400/40">This session</span>
{/if}
</div>
{#if session.ip}
<p class="text-xs text-zinc-400 font-mono">{session.ip}</p>
{/if}
<p class="text-xs text-zinc-500">
Signed in {formatDate(session.created_at)}
{#if session.last_seen && session.last_seen !== session.created_at}
· Last seen {formatDate(session.last_seen)}
{/if}
</p>
</div>
<button
onclick={() => revokeSession(session)}
disabled={revokingId === session.id}
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current
? 'bg-red-900/40 text-red-300 border border-red-700/60 hover:bg-red-900/70'
: 'bg-zinc-600/60 text-zinc-300 border border-zinc-500/50 hover:bg-zinc-600'}"
>
{revokingId === session.id ? '…' : session.is_current ? 'Sign out' : 'End'}
</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- ── Change password ──────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
<h2 class="text-lg font-semibold text-zinc-100">Change password</h2>
{#if form?.error}
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
{form.error}
</div>
{/if}
{#if pwSuccess}
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
Password changed successfully.
</div>
{/if}
<form
method="POST"
action="?/changePassword"
use:enhance={() => {
pwSubmitting = true;
return async ({ update }) => {
pwSubmitting = false;
await update();
};
}}
class="space-y-4"
>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="current">Current password</label>
<input
id="current"
name="current"
type="password"
autocomplete="current-password"
required
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="next">New password</label>
<input
id="next"
name="next"
type="password"
autocomplete="new-password"
required
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="confirm">Confirm new password</label>
<input
id="confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
</div>
<button
type="submit"
disabled={pwSubmitting}
class="px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-60"
>
{pwSubmitting ? 'Updating…' : 'Update password'}
</button>
</form>
</section>
</div>