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:
@@ -667,3 +667,112 @@ export async function getAudioTime(
|
||||
if (!row || !row.audio_time) return null;
|
||||
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(() => {})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user