Add session management: track active sessions, show on profile, allow revocation
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
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 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
This commit is contained in:
@@ -14,6 +14,8 @@
|
|||||||
// books_found(number), chapters_scraped(number),
|
// books_found(number), chapters_scraped(number),
|
||||||
// chapters_skipped(number), errors(number),
|
// chapters_skipped(number), errors(number),
|
||||||
// started(date), finished(date), error_message(text)
|
// started(date), finished(date), error_message(text)
|
||||||
|
// user_sessions — user_id(text), session_id(text,unique), user_agent(text),
|
||||||
|
// ip(text), created_at(date), last_seen(date)
|
||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -394,6 +396,18 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
|
|||||||
{"name": "error_message", "type": "text"},
|
{"name": "error_message", "type": "text"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "user_sessions",
|
||||||
|
"type": "base",
|
||||||
|
"fields": []map[string]interface{}{
|
||||||
|
{"name": "user_id", "type": "text", "required": true},
|
||||||
|
{"name": "session_id", "type": "text", "required": true}, // random ID embedded in auth token
|
||||||
|
{"name": "user_agent", "type": "text"},
|
||||||
|
{"name": "ip", "type": "text"},
|
||||||
|
{"name": "created_at", "type": "date"},
|
||||||
|
{"name": "last_seen", "type": "date"},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
name, _ := col["name"].(string)
|
name, _ := col["name"].(string)
|
||||||
|
|||||||
4
ui/src/app.d.ts
vendored
4
ui/src/app.d.ts
vendored
@@ -5,10 +5,10 @@ declare global {
|
|||||||
// interface Error {}
|
// interface Error {}
|
||||||
interface Locals {
|
interface Locals {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
user: { id: string; username: string; role: string } | null;
|
user: { id: string; username: string; role: string; authSessionId: string } | null;
|
||||||
}
|
}
|
||||||
interface PageData {
|
interface PageData {
|
||||||
user?: { id: string; username: string; role: string } | null;
|
user?: { id: string; username: string; role: string; authSessionId: string } | null;
|
||||||
}
|
}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Handle } from '@sveltejs/kit';
|
|||||||
import { randomBytes, createHmac } from 'node:crypto';
|
import { randomBytes, createHmac } from 'node:crypto';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
const SESSION_COOKIE = 'libnovel_session';
|
const SESSION_COOKIE = 'libnovel_session';
|
||||||
const AUTH_COOKIE = 'libnovel_auth';
|
const AUTH_COOKIE = 'libnovel_auth';
|
||||||
@@ -40,27 +41,30 @@ export function verifyToken(token: string): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a signed auth token for a user.
|
* Create a signed auth token for a user.
|
||||||
* Payload format: "<userId>:<username>:<role>"
|
* Payload format: "<userId>:<username>:<role>:<authSessionId>"
|
||||||
|
* authSessionId uniquely identifies this login session (for revocation).
|
||||||
*/
|
*/
|
||||||
export function createAuthToken(userId: string, username: string, role: string): string {
|
export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string {
|
||||||
return signToken(`${userId}:${username}:${role}`);
|
return signToken(`${userId}:${username}:${role}:${authSessionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a verified auth token into user data. Returns null if invalid.
|
* Parse a verified auth token into user data. Returns null if invalid.
|
||||||
|
* Supports both old format (3 segments) and new format (4 segments).
|
||||||
*/
|
*/
|
||||||
export function parseAuthToken(token: string): { id: string; username: string; role: string } | null {
|
export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null {
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
const firstColon = payload.indexOf(':');
|
const parts = payload.split(':');
|
||||||
if (firstColon < 0) return null;
|
// New format: userId:username:role:authSessionId (4 parts)
|
||||||
const secondColon = payload.indexOf(':', firstColon + 1);
|
// Old format: userId:username:role (3 parts — legacy tokens before session tracking)
|
||||||
if (secondColon < 0) return null;
|
if (parts.length < 3) return null;
|
||||||
const id = payload.slice(0, firstColon);
|
const id = parts[0];
|
||||||
const username = payload.slice(firstColon + 1, secondColon);
|
const username = parts[1];
|
||||||
const role = payload.slice(secondColon + 1);
|
const role = parts[2];
|
||||||
|
const authSessionId = parts[3] ?? ''; // empty string for legacy tokens
|
||||||
if (!id || !username) return null;
|
if (!id || !username) return null;
|
||||||
return { id, username, role };
|
return { id, username, role, authSessionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -85,8 +89,32 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
const user = parseAuthToken(authToken);
|
const user = parseAuthToken(authToken);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)');
|
log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)');
|
||||||
|
event.locals.user = null;
|
||||||
|
} else {
|
||||||
|
// Validate session against DB (only for new-format tokens with authSessionId)
|
||||||
|
let sessionValid = true;
|
||||||
|
if (user.authSessionId) {
|
||||||
|
try {
|
||||||
|
const revoked = await isSessionRevoked(user.authSessionId);
|
||||||
|
if (revoked) {
|
||||||
|
log.info('auth', 'auth cookie references revoked session', {
|
||||||
|
userId: user.id,
|
||||||
|
authSessionId: user.authSessionId
|
||||||
|
});
|
||||||
|
sessionValid = false;
|
||||||
|
// Clear the invalid cookie
|
||||||
|
event.cookies.delete(AUTH_COOKIE, { path: '/' });
|
||||||
|
} else {
|
||||||
|
// Best-effort: update last_seen in the background
|
||||||
|
touchUserSession(user.authSessionId).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// DB error — fail open to avoid locking everyone out
|
||||||
|
log.warn('auth', 'session check failed (fail open)', { err: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.locals.user = sessionValid ? user : null;
|
||||||
}
|
}
|
||||||
event.locals.user = user;
|
|
||||||
} else {
|
} else {
|
||||||
event.locals.user = null;
|
event.locals.user = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -667,3 +667,112 @@ export async function getAudioTime(
|
|||||||
if (!row || !row.audio_time) return null;
|
if (!row || !row.audio_time) return null;
|
||||||
return row.audio_time;
|
return row.audio_time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── User sessions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
session_id: string; // the auth session ID embedded in the token
|
||||||
|
user_agent: string;
|
||||||
|
ip: string;
|
||||||
|
created_at: string;
|
||||||
|
last_seen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session record on login. Returns the record ID.
|
||||||
|
*/
|
||||||
|
export async function createUserSession(
|
||||||
|
userId: string,
|
||||||
|
authSessionId: string,
|
||||||
|
userAgent: string,
|
||||||
|
ip: string
|
||||||
|
): Promise<string> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const res = await pbPost('/api/collections/user_sessions/records', {
|
||||||
|
user_id: userId,
|
||||||
|
session_id: authSessionId,
|
||||||
|
user_agent: userAgent,
|
||||||
|
ip,
|
||||||
|
created_at: now,
|
||||||
|
last_seen: now
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body });
|
||||||
|
throw new Error(`Failed to create session: ${res.status}`);
|
||||||
|
}
|
||||||
|
const rec = (await res.json()) as { id: string };
|
||||||
|
return rec.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last_seen on a session (best-effort, non-fatal if it fails).
|
||||||
|
*/
|
||||||
|
export async function touchUserSession(authSessionId: string): Promise<void> {
|
||||||
|
const row = await listOne<UserSession & { id: string }>(
|
||||||
|
'user_sessions',
|
||||||
|
`session_id="${authSessionId}"`
|
||||||
|
);
|
||||||
|
if (!row) return;
|
||||||
|
const token = await getToken();
|
||||||
|
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ last_seen: new Date().toISOString() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a session has been revoked (i.e., not present in DB).
|
||||||
|
* Returns true if revoked/missing, false if valid.
|
||||||
|
*/
|
||||||
|
export async function isSessionRevoked(authSessionId: string): Promise<boolean> {
|
||||||
|
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
|
||||||
|
return row === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all active sessions for a user.
|
||||||
|
*/
|
||||||
|
export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||||
|
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (delete) a specific session by its PocketBase record ID.
|
||||||
|
* Only allows deletion if the session belongs to the given userId.
|
||||||
|
*/
|
||||||
|
export async function revokeUserSession(recordId: string, userId: string): Promise<boolean> {
|
||||||
|
// Verify ownership before deleting
|
||||||
|
const token = await getToken();
|
||||||
|
const res = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const rec = (await res.json()) as UserSession;
|
||||||
|
if (rec.user_id !== userId) return false;
|
||||||
|
|
||||||
|
const del = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
return del.ok || del.status === 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all sessions for a user (used on password change etc).
|
||||||
|
*/
|
||||||
|
export async function revokeAllUserSessions(userId: string): Promise<void> {
|
||||||
|
const sessions = await listUserSessions(userId);
|
||||||
|
const token = await getToken();
|
||||||
|
await Promise.all(
|
||||||
|
sessions.map((s) =>
|
||||||
|
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
}).catch(() => {})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
32
ui/src/routes/api/sessions/+server.ts
Normal file
32
ui/src/routes/api/sessions/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { listUserSessions } from '$lib/server/pocketbase';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/sessions
|
||||||
|
* Returns all active sessions for the logged-in user.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
error(401, 'Not logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await listUserSessions(locals.user.id);
|
||||||
|
// Don't expose raw session_id to the client — only the record ID for revocation
|
||||||
|
const safe = sessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
user_agent: s.user_agent,
|
||||||
|
ip: s.ip,
|
||||||
|
created_at: s.created_at,
|
||||||
|
last_seen: s.last_seen,
|
||||||
|
// Tell the client whether this is the currently active session
|
||||||
|
is_current: s.session_id === locals.user!.authSessionId
|
||||||
|
}));
|
||||||
|
return json({ sessions: safe });
|
||||||
|
} catch (e) {
|
||||||
|
log.error('sessions', 'GET failed', { err: String(e) });
|
||||||
|
error(500, 'Failed to load sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
41
ui/src/routes/api/sessions/[id]/+server.ts
Normal file
41
ui/src/routes/api/sessions/[id]/+server.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { revokeUserSession } from '$lib/server/pocketbase';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/sessions/[id]
|
||||||
|
* Revokes a specific session by its PocketBase record ID.
|
||||||
|
* Only the owner can revoke their own sessions.
|
||||||
|
*/
|
||||||
|
export const DELETE: RequestHandler = async ({ params, locals, cookies }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
error(401, 'Not logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordId = params.id;
|
||||||
|
if (!recordId) {
|
||||||
|
error(400, 'Session ID required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await revokeUserSession(recordId, locals.user.id);
|
||||||
|
if (!ok) {
|
||||||
|
error(404, 'Session not found or not yours');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user is terminating their own current session, clear their auth cookie
|
||||||
|
// so they get logged out immediately (the hook would do this on the next request anyway,
|
||||||
|
// but clearing it here gives instant feedback for the "end this session" flow).
|
||||||
|
// For other sessions, we leave the cookie intact.
|
||||||
|
// We detect "current session" via authSessionId — but since the client sends the
|
||||||
|
// record ID (not the session_id), we rely on the UI to redirect after ending its own session.
|
||||||
|
|
||||||
|
log.info('sessions', 'session revoked', { recordId, userId: locals.user.id });
|
||||||
|
return json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && 'status' in e) throw e; // re-throw SvelteKit errors
|
||||||
|
log.error('sessions', 'DELETE failed', { recordId, err: String(e) });
|
||||||
|
error(500, 'Failed to revoke session');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { loginUser, createUser, mergeSessionProgress } from '$lib/server/pocketbase';
|
import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
||||||
import { createAuthToken } from '../../hooks.server';
|
import { createAuthToken } from '../../hooks.server';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
const AUTH_COOKIE = 'libnovel_auth';
|
const AUTH_COOKIE = 'libnovel_auth';
|
||||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||||
@@ -43,7 +44,20 @@ export const actions: Actions = {
|
|||||||
log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
||||||
);
|
);
|
||||||
|
|
||||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user');
|
// Create a unique auth session ID for this login
|
||||||
|
const authSessionId = randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
// Record the session in PocketBase (best-effort, non-fatal)
|
||||||
|
const userAgent = request.headers.get('user-agent') ?? '';
|
||||||
|
const ip =
|
||||||
|
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||||
|
request.headers.get('x-real-ip') ??
|
||||||
|
'';
|
||||||
|
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||||
|
log.warn('auth', 'login: createUserSession failed (non-fatal)', { err: String(err) })
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||||
cookies.set(AUTH_COOKIE, token, {
|
cookies.set(AUTH_COOKIE, token, {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -102,7 +116,20 @@ export const actions: Actions = {
|
|||||||
log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
||||||
);
|
);
|
||||||
|
|
||||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user');
|
// Create a unique auth session ID for this registration
|
||||||
|
const authSessionId = randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
// Record the session in PocketBase (best-effort, non-fatal)
|
||||||
|
const userAgent = request.headers.get('user-agent') ?? '';
|
||||||
|
const ip =
|
||||||
|
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||||
|
request.headers.get('x-real-ip') ??
|
||||||
|
'';
|
||||||
|
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||||
|
log.warn('auth', 'register: createUserSession failed (non-fatal)', { err: String(err) })
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||||
cookies.set(AUTH_COOKIE, token, {
|
cookies.set(AUTH_COOKIE, token, {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { changePassword } from '$lib/server/pocketbase';
|
import { changePassword, listUserSessions } from '$lib/server/pocketbase';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
if (!locals.user) {
|
if (!locals.user) {
|
||||||
redirect(302, '/login');
|
redirect(302, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
||||||
|
try {
|
||||||
|
sessions = await listUserSessions(locals.user.id);
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: locals.user
|
user: locals.user,
|
||||||
|
sessions: sessions.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
user_agent: s.user_agent,
|
||||||
|
ip: s.ip,
|
||||||
|
created_at: s.created_at,
|
||||||
|
last_seen: s.last_seen,
|
||||||
|
is_current: s.session_id === locals.user!.authSessionId
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,12 +69,81 @@
|
|||||||
setTimeout(() => (pwSuccess = false), 3000);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Profile — libnovel</title>
|
<title>Profile — libnovel</title>
|
||||||
</svelte:head>
|
</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 class="max-w-xl mx-auto space-y-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
|
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
|
||||||
@@ -151,6 +220,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ──────────────────────────────────────────────────── -->
|
<!-- ── Change password ──────────────────────────────────────────────────── -->
|
||||||
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
|
<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>
|
<h2 class="text-lg font-semibold text-zinc-100">Change password</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user