diff --git a/e2e/sessions.spec.ts b/e2e/sessions.spec.ts new file mode 100644 index 0000000..19a86c4 --- /dev/null +++ b/e2e/sessions.spec.ts @@ -0,0 +1,126 @@ +import { expect, test, type Browser, type Page } from '@playwright/test' + +import { resetUserData } from './helpers/db' +import { navigateToSettings } from './helpers/navigation' +import { login, registerUser, uniqueUsername } from './helpers/users' + +/** + * Session management E2E — real RPCs and realtime broadcasts against the + * production preview build. + * + * Tests exercise the full cross-device revocation chain: RPC deletion → + * Supabase Realtime broadcast → useSessionUpdateListener → isSessionValid → + * logoutUser → redirect + toast. Two browser contexts simulate two devices + * logged in as the same user. + * + * `beforeEach` truncates `auth.users` + `private.rate_limits` so pre-auth + * RPCs don't trip the shared-IP rate limiter. Per-test browser contexts + * isolate Supabase sessions. + */ + +const PASSWORD = 'TestPass123!' + +/** Auto-retry budget for cross-session assertions: realtime delivery + refetch. */ +const CROSS_SESSION_TIMEOUT = 20_000 + +/** Safety margin so B's realtime channel is SUBSCRIBED before A mutates. */ +const SUBSCRIBE_SETTLE_MS = 1500 + +/** + * Opens a second browser context (= a second device) and logs in as the same + * user via the real /login flow. Returns B's page and a `close` that tears + * the context down. Each test creates and closes its own second context. + */ +async function openSecondSession( + browser: Browser, + username: string, + password: string, +): Promise<{ pageB: Page; close: () => Promise }> { + const contextB = await browser.newContext() + const pageB = await contextB.newPage() + await login(pageB, username, password) + return { pageB, close: () => contextB.close() } +} + +test.describe('sessions', () => { + test.beforeEach(async () => { + await resetUserData() + }) + + test('session list shows the current session in Settings', async ({ page }, testInfo) => { + const username = uniqueUsername('sesslist') + await registerUser(page, username, PASSWORD) + + // Navigate to Settings — SessionSection is lazy-loaded, so wait for it. + await navigateToSettings(page, testInfo) + await page.waitForURL('**/settings') + + // The session card title should be visible. + await expect(page.getByText('Sessions', { exact: true })).toBeVisible() + + // The current session row should show "Current device" — this means the + // session list RPC returned data and getCurrentSessionId matched a row. + await expect(page.getByText('Current device')).toHaveCount(2) + + // The browser/OS should be parsed from the User-Agent — not "Unknown". + // Playwright's Chromium UA contains "Chrome" and either "Windows", "macOS", + // or "Linux". The session row renders "{browser} · {ip} · Last active: {time}". + // Assert that at least one session row contains a real browser name. + const sessionBrowser = page.getByTestId('session-browser') + await expect(sessionBrowser.first()).toContainText(/Chrome|HeadlessChrome|Edge|Firefox/) + }) + + test('revoking another session force-logs out the other device', async ({ page: pageA, browser }, testInfo) => { + const username = uniqueUsername('sessrevoke') + await registerUser(pageA, username, PASSWORD) + + // Open a second browser context and log in as the same user. + const sessionB = await openSecondSession(browser, username, PASSWORD) + try { + const { pageB } = sessionB + + // Let B's realtime channel finish subscribing before A acts. + await pageB.waitForTimeout(SUBSCRIBE_SETTLE_MS) + + // A navigates to Settings and sees 2 sessions. + await navigateToSettings(pageA, testInfo) + await pageA.waitForURL('**/settings') + await expect(pageA.getByText('Sessions', { exact: true })).toBeVisible() + + // Wait for sessions to load — at least one revoke button should appear. + // (The current session row doesn't have one, so this asserts ≥2 sessions.) + await expect(pageA.getByTestId('session-revoke-all')).toBeVisible() + + // A clicks "Revoke" on the first other session. + // Find a revoke button that is NOT the current session's label. + // SessionRow renders + )} + + ) +} + +export { SessionRow } diff --git a/src/features/settings/ui/SessionSection.tsx b/src/features/settings/ui/SessionSection.tsx new file mode 100644 index 0000000..6db56f4 --- /dev/null +++ b/src/features/settings/ui/SessionSection.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LogOut } from 'lucide-react' +import { toast } from 'sonner' + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' +import { Separator } from '@/shared/ui/separator' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/shared/ui/alert-dialog' +import { Spinner } from '@/shared/ui/Spinner' +import { useAuthStore } from '@/features/auth/model/auth-store' +import { getCurrentSessionId } from '@/shared/auth/session-utils' +import { parseUserAgent } from '@/features/settings/lib/parse-user-agent' +import { useActiveSessions, useRevokeSession, useRevokeOtherSessions } from '@/features/settings/model/use-session' +import { SessionRow } from '@/features/settings/ui/SessionRow' + +type RevokeMode = { type: 'single'; sessionId: string } | { type: 'all' } + +function SessionSection() { + const { t } = useTranslation('settings') + const [revokeMode, setRevokeMode] = useState(null) + + const accessToken = useAuthStore((s) => s.session?.accessToken) + const currentSessionId = accessToken ? getCurrentSessionId(accessToken) : null + + const { data: sessions, isLoading } = useActiveSessions() + const revokeSessionMutation = useRevokeSession() + const revokeOtherSessionsMutation = useRevokeOtherSessions() + + const otherSessions = (sessions ?? []).filter((s) => s.id !== currentSessionId) + + function confirmRevoke() { + if (!revokeMode) return + if (revokeMode.type === 'single') { + revokeSessionMutation.mutate(revokeMode.sessionId, { + onSuccess: (deleted) => { + if (deleted) { + toast.success(t('session.revokeSuccess')) + } else { + toast.error(t('session.revokeFailed')) + } + }, + onError: () => { + toast.error(t('session.revokeFailed')) + }, + onSettled: () => { + setRevokeMode(null) + }, + }) + } else { + revokeOtherSessionsMutation.mutate(undefined, { + onSuccess: (count) => { + toast.success(t('session.revokeAllSuccess', { count })) + }, + onError: () => { + toast.error(t('session.revokeAllFailed')) + }, + onSettled: () => { + setRevokeMode(null) + }, + }) + } + } + + const isOpen = revokeMode !== null + const revokeTarget = + revokeMode?.type === 'single' ? (sessions ?? []).find((s) => s.id === revokeMode.sessionId) : null + const revokeTargetInfo = revokeTarget ? parseUserAgent(revokeTarget.user_agent) : null + + return ( + <> + + + {t('session.title')} + {t('session.description')} + + + {isLoading ? ( +
+ +
+ ) : !sessions || sessions.length === 0 ? ( +

{t('session.noSessions')}

+ ) : ( + <> + {sessions.map((session, i) => ( +
+ {i > 0 && } + setRevokeMode({ type: 'single', sessionId: session.id })} + /> +
+ ))} + {otherSessions.length > 0 && ( + <> + +
+ {t('session.revokeAll')} + +
+ + )} + + )} +
+
+ + !open && setRevokeMode(null)}> + + + + {revokeMode?.type === 'all' ? t('session.revokeAllConfirmTitle') : t('session.revokeConfirmTitle')} + + + {revokeMode?.type === 'all' + ? t('session.revokeAllConfirmBody') + : t('session.revokeConfirmBody', { + device: revokeTargetInfo + ? t('session.device', { browser: revokeTargetInfo.browser, os: revokeTargetInfo.os }) + : t('session.revokeConfirmBody_unknownDevice'), + })} + + + + {t('common:actions.cancel')} + + {revokeMode?.type === 'all' ? t('session.revokeAll') : t('session.revoke')} + + + + + + ) +} + +export { SessionSection } diff --git a/src/features/settings/ui/SettingsPage.test.tsx b/src/features/settings/ui/SettingsPage.test.tsx index b76ddc7..3aa5555 100644 --- a/src/features/settings/ui/SettingsPage.test.tsx +++ b/src/features/settings/ui/SettingsPage.test.tsx @@ -1,7 +1,17 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@/test/utils' import { useAuthStore } from '@/features/auth/model/auth-store' +// SessionSection is lazy-loaded and calls getActiveSessions on mount. +// Mock the session API so the component doesn't make a real Supabase call +// (which would fail without env vars and slow down the test). +vi.mock('@/shared/api/supabase-session', () => ({ + getActiveSessions: vi.fn(() => Promise.resolve([])), + revokeSession: vi.fn(() => Promise.resolve(true)), + revokeOtherSessions: vi.fn(() => Promise.resolve(0)), + isSessionValid: vi.fn(() => Promise.resolve(true)), +})) + import { SettingsPage } from './SettingsPage' describe('SettingsPage', () => { @@ -18,11 +28,13 @@ describe('SettingsPage', () => { expect(screen.getByText('About')).toBeInTheDocument() }) - it('renders sections in Account → Preferences → Security → About order', () => { + it('renders sections in Account → Preferences → Security → About → Sessions order', async () => { const { container } = render() + // SessionSection is lazy-loaded, wait for it to appear + await screen.findByText('Sessions', {}, { timeout: 5000 }) const sectionTitles = Array.from(container.querySelectorAll('[data-slot="card-title"]')) const titleTexts = sectionTitles.map((el) => el.textContent) - expect(titleTexts).toEqual(['Account', 'Preferences', 'Security', 'About']) + expect(titleTexts).toEqual(['Account', 'Preferences', 'Security', 'About', 'Sessions']) }) it('renders username from auth store in account section', () => { diff --git a/src/features/settings/ui/SettingsPage.tsx b/src/features/settings/ui/SettingsPage.tsx index c1c0634..5e2f718 100644 --- a/src/features/settings/ui/SettingsPage.tsx +++ b/src/features/settings/ui/SettingsPage.tsx @@ -1,10 +1,16 @@ +import { lazy, Suspense } from 'react' import { useTranslation } from 'react-i18next' +import { CardSkeleton } from '@/shared/ui/CardSkeleton' import { AccountSection } from '@/features/settings/ui/AccountSection' import { PreferencesSection } from '@/features/settings/ui/PreferencesSection' import { SecuritySection } from '@/features/settings/ui/SecuritySection' import { AboutSection } from '@/features/settings/ui/AboutSection' +const SessionSection = lazy(() => + import('@/features/settings/ui/SessionSection').then((m) => ({ default: m.SessionSection })), +) + function SettingsPage() { const { t } = useTranslation('settings') @@ -17,6 +23,11 @@ function SettingsPage() { +
+ }> + + +
) } diff --git a/src/shared/api/supabase-session.test.ts b/src/shared/api/supabase-session.test.ts new file mode 100644 index 0000000..d18ac55 --- /dev/null +++ b/src/shared/api/supabase-session.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + GET_ACTIVE_SESSIONS_RPC, + REVOKE_SESSION_RPC, + REVOKE_OTHER_SESSIONS_RPC, + IS_SESSION_VALID_RPC, +} from '@/shared/types/supabase-schema' + +const mockRpc = vi.fn() + +vi.mock('@/shared/api/supabase-client', () => ({ + getSupabase: () => ({ rpc: mockRpc }), +})) + +// Import after mock setup +import { getActiveSessions, revokeSession, revokeOtherSessions, isSessionValid } from '@/shared/api/supabase-session' + +describe('supabase-session', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getActiveSessions', () => { + it('calls the correct RPC and returns data', async () => { + const sessions = [ + { + id: 'session-1', + created_at: '2024-01-01', + updated_at: '2024-01-02', + user_agent: 'Chrome', + ip: '1.2.3.4', + not_after: null, + }, + ] + mockRpc.mockResolvedValue({ data: sessions, error: null }) + + const result = await getActiveSessions() + + expect(mockRpc).toHaveBeenCalledWith(GET_ACTIVE_SESSIONS_RPC) + expect(result).toEqual(sessions) + }) + + it('returns empty array when data is null', async () => { + mockRpc.mockResolvedValue({ data: null, error: null }) + + const result = await getActiveSessions() + + expect(result).toEqual([]) + }) + + it('throws wrapped error on RPC failure', async () => { + mockRpc.mockResolvedValue({ data: null, error: new Error('RPC failed') }) + + await expect(getActiveSessions()).rejects.toThrow() + }) + }) + + describe('revokeSession', () => { + it('calls the correct RPC with session ID and returns result', async () => { + mockRpc.mockResolvedValue({ data: true, error: null }) + + const result = await revokeSession('session-1') + + expect(mockRpc).toHaveBeenCalledWith(REVOKE_SESSION_RPC, { p_session_id: 'session-1' }) + expect(result).toBe(true) + }) + + it('returns false when session was not found', async () => { + mockRpc.mockResolvedValue({ data: false, error: null }) + + const result = await revokeSession('nonexistent') + + expect(result).toBe(false) + }) + + it('throws wrapped error on RPC failure', async () => { + mockRpc.mockResolvedValue({ data: null, error: new Error('Cannot revoke current session') }) + + await expect(revokeSession('current-session')).rejects.toThrow() + }) + }) + + describe('revokeOtherSessions', () => { + it('calls the correct RPC and returns count', async () => { + mockRpc.mockResolvedValue({ data: 3, error: null }) + + const result = await revokeOtherSessions() + + expect(mockRpc).toHaveBeenCalledWith(REVOKE_OTHER_SESSIONS_RPC) + expect(result).toBe(3) + }) + + it('throws wrapped error on RPC failure', async () => { + mockRpc.mockResolvedValue({ data: null, error: new Error('Not authenticated') }) + + await expect(revokeOtherSessions()).rejects.toThrow() + }) + }) + + describe('isSessionValid', () => { + it('calls the correct RPC and returns true when session is valid', async () => { + mockRpc.mockResolvedValue({ data: true, error: null }) + + const result = await isSessionValid() + + expect(mockRpc).toHaveBeenCalledWith(IS_SESSION_VALID_RPC) + expect(result).toBe(true) + }) + + it('returns false when session has been revoked', async () => { + mockRpc.mockResolvedValue({ data: false, error: null }) + + const result = await isSessionValid() + + expect(result).toBe(false) + }) + + it('throws wrapped error on RPC failure', async () => { + mockRpc.mockResolvedValue({ data: null, error: new Error('RPC failed') }) + + await expect(isSessionValid()).rejects.toThrow() + }) + }) +}) diff --git a/src/shared/api/supabase-session.ts b/src/shared/api/supabase-session.ts new file mode 100644 index 0000000..7504c45 --- /dev/null +++ b/src/shared/api/supabase-session.ts @@ -0,0 +1,58 @@ +import { getSupabase } from '@/shared/api/supabase-client' +import { wrapApiError } from '@/shared/api/api-errors' +import { + GET_ACTIVE_SESSIONS_RPC, + REVOKE_SESSION_RPC, + REVOKE_OTHER_SESSIONS_RPC, + IS_SESSION_VALID_RPC, +} from '@/shared/types/supabase-schema' +import type { ActiveSession } from '@/shared/types/entities/user.types' + +/** + * Fetch all active sessions for the current user. + * Calls the get_active_sessions SECURITY DEFINER RPC. + */ +export async function getActiveSessions(): Promise { + const { data, error } = await getSupabase().rpc(GET_ACTIVE_SESSIONS_RPC) + + if (error) throw wrapApiError(error) + return (data ?? []) as ActiveSession[] +} + +/** + * Revoke a specific session by ID. + * The RPC prevents revoking the current session (raises an exception). + * @returns true if the session was found and deleted, false otherwise + */ +export async function revokeSession(sessionId: string): Promise { + const { data, error } = await getSupabase().rpc(REVOKE_SESSION_RPC, { + p_session_id: sessionId, + }) + + if (error) throw wrapApiError(error) + return data as boolean +} + +/** + * Revoke all sessions except the current one. + * The current session is identified by the session_id claim in the JWT. + * @returns the number of sessions revoked + */ +export async function revokeOtherSessions(): Promise { + const { data, error } = await getSupabase().rpc(REVOKE_OTHER_SESSIONS_RPC) + + if (error) throw wrapApiError(error) + return data as number +} + +/** + * Check whether the current session is still valid. + * Returns FALSE if the session has been revoked (deleted from auth.sessions) + * or the user is not authenticated. Used to detect cross-device revocation. + */ +export async function isSessionValid(): Promise { + const { data, error } = await getSupabase().rpc(IS_SESSION_VALID_RPC) + + if (error) throw wrapApiError(error) + return data as boolean +} diff --git a/src/shared/auth/session-utils.test.ts b/src/shared/auth/session-utils.test.ts new file mode 100644 index 0000000..acc76c9 --- /dev/null +++ b/src/shared/auth/session-utils.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { getCurrentSessionId } from '@/shared/auth/session-utils' + +describe('getCurrentSessionId', () => { + function makeToken(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const encodedPayload = btoa(JSON.stringify(payload)) + const signature = 'sig' + return `${header}.${encodedPayload}.${signature}` + } + + it('extracts session_id from a valid JWT', () => { + const token = makeToken({ sub: 'user-123', session_id: 'abc-def-ghi' }) + expect(getCurrentSessionId(token)).toBe('abc-def-ghi') + }) + + it('returns null when session_id is missing from the payload', () => { + const token = makeToken({ sub: 'user-123' }) + expect(getCurrentSessionId(token)).toBeNull() + }) + + it('returns null for an invalid token format', () => { + expect(getCurrentSessionId('not-a-jwt')).toBeNull() + }) + + it('returns null for an empty string', () => { + expect(getCurrentSessionId('')).toBeNull() + }) + + it('handles URL-safe base64 encoding in the payload', () => { + // Simulate a payload with URL-safe base64 chars (- and _) + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/=/g, '') + const payload = btoa(JSON.stringify({ session_id: 'test-123' })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + const token = `${header}.${payload}.sig` + expect(getCurrentSessionId(token)).toBe('test-123') + }) +}) diff --git a/src/shared/auth/session-utils.ts b/src/shared/auth/session-utils.ts new file mode 100644 index 0000000..0318852 --- /dev/null +++ b/src/shared/auth/session-utils.ts @@ -0,0 +1,20 @@ +/** + * Extract the current session ID from a JWT access token. + * + * Supabase Auth includes a `session_id` claim in the JWT payload that + * uniquely identifies the session. This is used client-side to mark the + * "current" session in the session list UI — the server-side RPCs use + * the same claim (via `current_setting`) to identify the current session + * for self-revocation protection. + * + * @returns The session ID string, or null if the token cannot be parsed. + */ +export function getCurrentSessionId(accessToken: string): string | null { + try { + const payload = accessToken.split('.')[1] + const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))) + return decoded.session_id ?? null + } catch { + return null + } +} diff --git a/src/shared/auth/supabase-adapter.test.ts b/src/shared/auth/supabase-adapter.test.ts index 14ef6f1..c2d422e 100644 --- a/src/shared/auth/supabase-adapter.test.ts +++ b/src/shared/auth/supabase-adapter.test.ts @@ -253,6 +253,36 @@ describe('SupabaseAuthAdapter — onAuthStateChange', () => { }) }) +describe('SupabaseAuthAdapter — logout', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('calls signOut with local scope', async () => { + mockSignOut.mockResolvedValue({ error: null }) + + const { authAdapter } = await import('./supabase-adapter') + await authAdapter.logout() + + expect(mockSignOut).toHaveBeenCalledWith({ scope: 'local' }) + }) + + it('throws AuthError on signOut failure', async () => { + mockSignOut.mockResolvedValue({ + error: { status: 500, message: 'Internal server error' }, + }) + + const { authAdapter } = await import('./supabase-adapter') + + try { + await authAdapter.logout() + expect.unreachable('should have thrown') + } catch (e) { + expect(e).toBeInstanceOf(AuthError) + } + }) +}) + describe('SupabaseAuthAdapter — updatePassword', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/shared/auth/supabase-adapter.ts b/src/shared/auth/supabase-adapter.ts index 0d9f953..add9817 100644 --- a/src/shared/auth/supabase-adapter.ts +++ b/src/shared/auth/supabase-adapter.ts @@ -48,7 +48,7 @@ class SupabaseAuthAdapter implements IAuthAdapter { } async logout(): Promise { - const { error } = await getSupabase().auth.signOut() + const { error } = await getSupabase().auth.signOut({ scope: 'local' }) if (error) throw wrapSupabaseAuthError(error) } diff --git a/src/shared/crypto/vault/crypto-store.ts b/src/shared/crypto/vault/crypto-store.ts index 1c7031e..2a4f1ea 100644 --- a/src/shared/crypto/vault/crypto-store.ts +++ b/src/shared/crypto/vault/crypto-store.ts @@ -41,6 +41,7 @@ function setQueryClient(client: QueryClient) { function clearVaultQueries() { queryClientRef?.removeQueries({ queryKey: queryKeys.field.all }) queryClientRef?.removeQueries({ queryKey: queryKeys.entry.all }) + queryClientRef?.removeQueries({ queryKey: queryKeys.session.all }) } const useCryptoStore = create()((set) => ({ diff --git a/src/shared/i18n/config.test.ts b/src/shared/i18n/config.test.ts index c20f838..7c7757d 100644 --- a/src/shared/i18n/config.test.ts +++ b/src/shared/i18n/config.test.ts @@ -33,7 +33,7 @@ describe('i18n configuration', () => { it('translates English common strings', () => { expect(testI18n.t('app.name')).toBe('Cipher Note') expect(testI18n.t('app.tagline')).toBe('Your notes. Your privacy. Your control.') - expect(testI18n.t('app.version')).toBe('v1.0.0') + expect(testI18n.t('app.version')).toBe('v1.1.0') expect(testI18n.t('app.githubUrl')).toBe('https://github.com/VitekHub/cipher-note/') expect(testI18n.t('app.license')).toBe('MIT License') }) @@ -43,7 +43,7 @@ describe('i18n configuration', () => { expect(testI18n.language).toBe('cs') expect(testI18n.t('app.name')).toBe('Cipher Note') expect(testI18n.t('app.tagline')).toBe('Vaše poznámky. Vaše soukromí. Vaše kontrola.') - expect(testI18n.t('app.version')).toBe('v1.0.0') + expect(testI18n.t('app.version')).toBe('v1.1.0') expect(testI18n.t('app.githubUrl')).toBe('https://github.com/VitekHub/cipher-note/') expect(testI18n.t('app.license')).toBe('MIT Licence') }) diff --git a/src/shared/i18n/locales/cs/auth.json b/src/shared/i18n/locales/cs/auth.json index 466e87a..f4d963f 100644 --- a/src/shared/i18n/locales/cs/auth.json +++ b/src/shared/i18n/locales/cs/auth.json @@ -149,6 +149,9 @@ "unexpectedError": "Došlo k neočekávané chybě. Zkuste to znovu." } }, + "session": { + "revokedElsewhere": "Vaše relace byla zrušena z jiného zařízení. Byli jste odhlášeni." + }, "deleteAccount": { "title": "Smazat účet", "description": "⚠️ Tato akce trvale smaže váš účet a všechna vaše data. Nelze ji vrátit zpět.", diff --git a/src/shared/i18n/locales/cs/common.json b/src/shared/i18n/locales/cs/common.json index 5bb2c0e..111231f 100644 --- a/src/shared/i18n/locales/cs/common.json +++ b/src/shared/i18n/locales/cs/common.json @@ -2,7 +2,7 @@ "app": { "name": "Cipher Note", "tagline": "Vaše poznámky. Vaše soukromí. Vaše kontrola.", - "version": "v1.0.0", + "version": "v1.1.0", "githubUrl": "https://github.com/VitekHub/cipher-note/", "license": "MIT Licence" }, diff --git a/src/shared/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json index ce5dade..6e1c6fa 100644 --- a/src/shared/i18n/locales/cs/settings.json +++ b/src/shared/i18n/locales/cs/settings.json @@ -34,6 +34,25 @@ "license": "Licence", "sourceCode": "Zdrojový kód" }, + "session": { + "title": "Relace", + "description": "Správa aktivních relací na vašich zařízeních.", + "currentDevice": "Toto zařízení", + "lastActive": "Poslední aktivita: {{time}}", + "noSessions": "Žádné aktivní relace", + "revoke": "Odhlásit", + "revokeAll": "Odhlásit všechny", + "revokeSuccess": "Relace odhlášena", + "revokeFailed": "Nepodařilo se odhlásit relaci", + "revokeAllSuccess": "Odhlášeno {{count}} relací", + "revokeAllFailed": "Nepodařilo se odhlásit relace", + "revokeConfirmTitle": "Odhlásit tuto relaci?", + "device": "{{browser}} na {{os}}", + "revokeConfirmBody": "Tím se odhlásí {{device}}. Všechny neuložené změny na tomto zařízení budou ztraceny.", + "revokeAllConfirmTitle": "Odhlásit všechny ostatní relace?", + "revokeConfirmBody_unknownDevice": "neznámé zařízení", + "revokeAllConfirmBody": "Tím se odhlásí všechna zařízení kromě tohoto. Všechny neuložené změny na těchto zařízeních budou ztraceny." + }, "keyRotation": { "field": { "title": "Název", diff --git a/src/shared/i18n/locales/en/auth.json b/src/shared/i18n/locales/en/auth.json index bbfa5e1..86e491a 100644 --- a/src/shared/i18n/locales/en/auth.json +++ b/src/shared/i18n/locales/en/auth.json @@ -149,6 +149,9 @@ "unexpectedError": "An unexpected error occurred. Please try again." } }, + "session": { + "revokedElsewhere": "You have been signed out because your session was revoked from another device" + }, "deleteAccount": { "title": "Delete Account", "description": "⚠️ This will permanently delete your account and all your data. This action cannot be undone.", diff --git a/src/shared/i18n/locales/en/common.json b/src/shared/i18n/locales/en/common.json index cd0d0b0..ee4fafc 100644 --- a/src/shared/i18n/locales/en/common.json +++ b/src/shared/i18n/locales/en/common.json @@ -2,7 +2,7 @@ "app": { "name": "Cipher Note", "tagline": "Your notes. Your privacy. Your control.", - "version": "v1.0.0", + "version": "v1.1.0", "githubUrl": "https://github.com/VitekHub/cipher-note/", "license": "MIT License" }, diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index fb8d3f6..0e29c95 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -34,6 +34,25 @@ "license": "License", "sourceCode": "Source code" }, + "session": { + "title": "Sessions", + "description": "Manage your active sessions across devices.", + "currentDevice": "Current device", + "lastActive": "Last active: {{time}}", + "noSessions": "No active sessions", + "revoke": "Revoke", + "revokeAll": "Revoke all others", + "revokeSuccess": "Session revoked", + "revokeFailed": "Failed to revoke session", + "revokeAllSuccess": "Revoked {{count}} session(s)", + "revokeAllFailed": "Failed to revoke sessions", + "revokeConfirmTitle": "Revoke this session?", + "device": "{{browser}} on {{os}}", + "revokeConfirmBody": "This will sign out {{device}}. Any unsaved changes on that device will be lost.", + "revokeAllConfirmTitle": "Revoke all other sessions?", + "revokeConfirmBody_unknownDevice": "an unknown device", + "revokeAllConfirmBody": "This will sign out all devices except this one. Any unsaved changes on those devices will be lost." + }, "keyRotation": { "field": { "title": "Title", diff --git a/src/shared/lib/query-keys.ts b/src/shared/lib/query-keys.ts index 4f5bf15..0c95d7b 100644 --- a/src/shared/lib/query-keys.ts +++ b/src/shared/lib/query-keys.ts @@ -15,4 +15,8 @@ export const queryKeys = { all: ['entry'] as const, list: (userId: string) => [...queryKeys.entry.all, userId] as const, }, + session: { + all: ['session'] as const, + list: ['session', 'list'] as const, + }, } as const diff --git a/src/shared/realtime/session-update.test.ts b/src/shared/realtime/session-update.test.ts new file mode 100644 index 0000000..15caed5 --- /dev/null +++ b/src/shared/realtime/session-update.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// --- Hoisted mocks: controllable Supabase client --- + +const ctx = vi.hoisted(() => { + const sendMock = vi.fn() + const removeChannel = vi.fn() + let currentChannel: Record | null = null + + const makeChannel = () => { + const handlers: Record void> = {} + const channel = { + on: vi.fn((_event: string, config: { event: string }, cb: () => void) => { + handlers[config.event] = cb + return channel + }), + subscribe: vi.fn(() => channel), + send: sendMock, + _handlers: handlers, + } + currentChannel = channel + return channel + } + + const client = { + channel: vi.fn(() => makeChannel()), + removeChannel, + } + + return { client, removeChannel, sendMock, currentRef: () => currentChannel } +}) + +vi.mock('@/shared/api/supabase-client', () => ({ getSupabase: () => ctx.client })) + +// --- Import after mocks --- + +import { sessionUpdateChannel } from '@/shared/realtime/session-update' + +const USER_ID = 'user-123' + +describe('SessionUpdateChannel', () => { + beforeEach(() => { + sessionUpdateChannel.unsubscribe() + ctx.removeChannel.mockClear() + ctx.client.channel.mockClear() + ctx.sendMock.mockClear() + }) + + describe('subscribe', () => { + it('creates a per-user broadcast channel', () => { + const onUpdate = vi.fn() + sessionUpdateChannel.subscribe(USER_ID, onUpdate) + + expect(ctx.client.channel).toHaveBeenCalledWith(`session-updates:${USER_ID}`) + }) + + it('invokes the callback when a sessions_updated broadcast arrives', () => { + const onUpdate = vi.fn() + sessionUpdateChannel.subscribe(USER_ID, onUpdate) + + const channel = ctx.currentRef() as Record void>> + const handler = channel._handlers['sessions_updated'] + expect(handler).toBeDefined() + + handler!() + expect(onUpdate).toHaveBeenCalledTimes(1) + }) + + it('tears down a prior subscription before opening a new one', () => { + const onUpdate1 = vi.fn() + const onUpdate2 = vi.fn() + + sessionUpdateChannel.subscribe(USER_ID, onUpdate1) + const firstChannel = ctx.currentRef() + + sessionUpdateChannel.subscribe(USER_ID, onUpdate2) + expect(ctx.removeChannel).toHaveBeenCalledTimes(1) + expect(ctx.removeChannel).toHaveBeenCalledWith(firstChannel) + expect(ctx.client.channel).toHaveBeenCalledTimes(2) + }) + }) + + describe('broadcastUpdate', () => { + it('sends a sessions_updated broadcast event on the per-user channel', () => { + sessionUpdateChannel.broadcastUpdate(USER_ID) + + expect(ctx.client.channel).toHaveBeenCalledWith(`session-updates:${USER_ID}`) + expect(ctx.sendMock).toHaveBeenCalledWith({ + type: 'broadcast', + event: 'sessions_updated', + payload: {}, + }) + }) + }) + + describe('unsubscribe', () => { + it('removes the channel and is idempotent', () => { + sessionUpdateChannel.subscribe(USER_ID, vi.fn()) + const channel = ctx.currentRef() + + sessionUpdateChannel.unsubscribe() + expect(ctx.removeChannel).toHaveBeenCalledTimes(1) + expect(ctx.removeChannel).toHaveBeenCalledWith(channel) + + // Second call is a no-op + sessionUpdateChannel.unsubscribe() + expect(ctx.removeChannel).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/shared/realtime/session-update.ts b/src/shared/realtime/session-update.ts new file mode 100644 index 0000000..caa93c8 --- /dev/null +++ b/src/shared/realtime/session-update.ts @@ -0,0 +1,63 @@ +import { getSupabase } from '@/shared/api/supabase-client' + +// Inferred from the singleton client so we don't depend on a specific realtime type export. +type RealtimeChannel = ReturnType['channel']> + +const SESSIONS_UPDATED_EVENT = 'sessions_updated' + +/** + * Broadcast channel for cross-device session update notifications. + * + * When a session is added or revoked, broadcasts a signal on a per-user + * channel. Subscribers check their own validity (force-logout if revoked) + * and refresh the active sessions query. Payload is intentionally empty — + * the database is the source of truth. + */ +class SessionUpdateChannel { + private channel: RealtimeChannel | null = null + + /** + * Subscribe to session update broadcasts for the given user. + * Calls `onUpdate` whenever a session is added or revoked. + */ + subscribe(userId: string, onUpdate: () => void): void { + // Defensive: tear down any prior subscription before opening a new one. + this.unsubscribe() + + const supabase = getSupabase() + + this.channel = supabase + .channel(`session-updates:${userId}`) + .on('broadcast', { event: SESSIONS_UPDATED_EVENT }, () => { + onUpdate() + }) + .subscribe() + } + + /** + * Broadcast a session update signal to other devices. + * Fire-and-forget: the sender does not await delivery confirmation. + */ + broadcastUpdate(userId: string): void { + const supabase = getSupabase() + const channel = supabase.channel(`session-updates:${userId}`) + + void channel.send({ + type: 'broadcast', + event: SESSIONS_UPDATED_EVENT, + payload: {}, + }) + } + + /** Unsubscribe from the session update channel. */ + unsubscribe(): void { + if (this.channel) { + getSupabase().removeChannel(this.channel) + this.channel = null + } + } +} + +const sessionUpdateChannel = new SessionUpdateChannel() + +export { sessionUpdateChannel, SessionUpdateChannel } diff --git a/src/shared/types/entities/user.types.ts b/src/shared/types/entities/user.types.ts index b63cfd9..befec79 100644 --- a/src/shared/types/entities/user.types.ts +++ b/src/shared/types/entities/user.types.ts @@ -8,3 +8,13 @@ export interface UserSession { accessToken: string expiresAt: number } + +/** Active session returned by the get_active_sessions RPC. */ +export interface ActiveSession { + id: string + created_at: string + updated_at: string + user_agent: string | null + ip: string | null + not_after: string | null +} diff --git a/src/shared/types/supabase-schema.ts b/src/shared/types/supabase-schema.ts index 11e21df..994f62b 100644 --- a/src/shared/types/supabase-schema.ts +++ b/src/shared/types/supabase-schema.ts @@ -27,6 +27,18 @@ export const ROTATE_FIELD_KEY_RPC = 'rotate_field_key' /** RPC function name for account deletion (authenticated, SECURITY DEFINER). */ export const DELETE_ACCOUNT_RPC = 'delete_account' +/** RPC function name for listing active sessions (authenticated, SECURITY DEFINER). */ +export const GET_ACTIVE_SESSIONS_RPC = 'get_active_sessions' + +/** RPC function name for revoking a specific session (authenticated, SECURITY DEFINER). */ +export const REVOKE_SESSION_RPC = 'revoke_session' + +/** RPC function name for revoking all sessions except the current one (authenticated, SECURITY DEFINER). */ +export const REVOKE_OTHER_SESSIONS_RPC = 'revoke_other_sessions' + +/** RPC function name for checking whether the current session is still valid (authenticated, SECURITY DEFINER). */ +export const IS_SESSION_VALID_RPC = 'is_session_valid' + /** Snake_case row delivered by Supabase for an `encrypted_fields` change. */ export interface EncryptedFieldRow { entry_id: string diff --git a/src/shared/ui/CardSkeleton.tsx b/src/shared/ui/CardSkeleton.tsx new file mode 100644 index 0000000..b2adea7 --- /dev/null +++ b/src/shared/ui/CardSkeleton.tsx @@ -0,0 +1,32 @@ +import { Card, CardContent, CardHeader } from '@/shared/ui/card' +import { Skeleton } from '@/shared/ui/skeleton' + +interface CardSkeletonProps { + /** Number of content rows (default: 2) */ + rows?: number + /** Title skeleton width class (default: 'w-40') */ + titleWidth?: string + /** Description skeleton width class (default: 'w-64') */ + descriptionWidth?: string +} + +function CardSkeleton({ rows = 2, titleWidth = 'w-40', descriptionWidth = 'w-64' }: CardSkeletonProps) { + return ( + + + + + + + {Array.from({ length: rows }, (_, i) => ( +
+ {i > 0 && } + +
+ ))} +
+
+ ) +} + +export { CardSkeleton } diff --git a/src/test/setup.ts b/src/test/setup.ts index 64944af..3e3df52 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -108,3 +108,4 @@ class IntersectionObserverMock { globalThis.IntersectionObserver = IntersectionObserverMock as unknown as typeof IntersectionObserver HTMLElement.prototype.scrollIntoView = vi.fn() +window.scrollTo = vi.fn() diff --git a/src/test/utils.tsx b/src/test/utils.tsx index cc65934..2f18060 100644 --- a/src/test/utils.tsx +++ b/src/test/utils.tsx @@ -2,6 +2,7 @@ import { render, renderHook } from '@testing-library/react' import type { ReactNode, ReactElement } from 'react' import { Suspense } from 'react' import { afterEach } from 'vitest' +import { act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ThemeProvider } from '@/shared/lib/theme-provider' import { AuthProvider } from '@/features/auth/ui/auth-provider' @@ -15,8 +16,11 @@ const testQueryClient = new QueryClient({ }, }) -afterEach(() => { +afterEach(async () => { testQueryClient.clear() + // Flush pending React state updates from async effects + // (subscriptions, promises, timers) to silence "not wrapped in act(...)" warnings + await act(async () => {}) }) function AllProviders({ children }: { children: ReactNode }) { diff --git a/supabase/migrations/00008_delete_account_rpc.sql b/supabase/migrations/00008_delete_account_rpc.sql index 1c9569d..50aadea 100644 --- a/supabase/migrations/00008_delete_account_rpc.sql +++ b/supabase/migrations/00008_delete_account_rpc.sql @@ -34,4 +34,4 @@ $$; REVOKE ALL ON FUNCTION public.delete_account() FROM PUBLIC; REVOKE ALL ON FUNCTION public.delete_account() FROM anon; -GRANT EXECUTE ON FUNCTION public.delete_account() TO authenticated; \ No newline at end of file +GRANT EXECUTE ON FUNCTION public.delete_account() TO authenticated; diff --git a/supabase/migrations/00009_session_rpc.sql b/supabase/migrations/00009_session_rpc.sql new file mode 100644 index 0000000..04ba2e0 --- /dev/null +++ b/supabase/migrations/00009_session_rpc.sql @@ -0,0 +1,147 @@ +-- ============================================ +-- Cipher Note: Session Management RPCs +-- ============================================ +-- SECURITY DEFINER RPCs for managing user sessions. +-- These functions allow authenticated users to: +-- 1. List their active sessions (browser, IP, last active time) +-- 2. Revoke a specific session (cannot revoke the current session) +-- 3. Revoke all sessions except the current one +-- 4. Check whether the current session is still valid +-- +-- The auth.sessions table is in the auth schema (not accessible to the +-- anon or authenticated roles directly), so these functions run as +-- SECURITY DEFINER to bypass RLS. +-- ============================================ + +-- RPC to list active sessions for the current user. +-- Returns display-safe columns only (no internal data like refresh tokens). +CREATE OR REPLACE FUNCTION public.get_active_sessions() +RETURNS TABLE ( + id UUID, + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + user_agent TEXT, + ip TEXT, + not_after TIMESTAMPTZ +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + RETURN QUERY + SELECT s.id, s.created_at, s.updated_at, s.user_agent, s.ip::TEXT, s.not_after + FROM auth.sessions s + WHERE s.user_id = auth.uid() + ORDER BY s.updated_at DESC; +END; +$$; + +-- RPC to revoke a specific session by ID. +-- Prevents self-revocation by checking the session_id claim in the JWT. +-- Returns TRUE if the session was found and deleted, FALSE otherwise. +CREATE OR REPLACE FUNCTION public.revoke_session(p_session_id UUID) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_current_session_id UUID; + v_deleted BOOLEAN; +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Extract the current session ID from the JWT to prevent self-revocation. + v_current_session_id := (current_setting('request.jwt.claims', true)::json ->> 'session_id')::UUID; + + IF p_session_id = v_current_session_id THEN + RAISE EXCEPTION 'Cannot revoke current session'; + END IF; + + DELETE FROM auth.sessions + WHERE id = p_session_id AND user_id = auth.uid(); + + GET DIAGNOSTICS v_deleted = ROW_COUNT; + RETURN v_deleted; +END; +$$; + +-- RPC to revoke all sessions except the current one. +-- The current session is identified by the session_id claim in the JWT. +-- Returns the number of sessions revoked. +CREATE OR REPLACE FUNCTION public.revoke_other_sessions() +RETURNS INTEGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user_id UUID := auth.uid(); + v_current_session_id UUID; + v_revoked_count INTEGER; +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Extract the current session ID from the JWT. + v_current_session_id := (current_setting('request.jwt.claims', true)::json ->> 'session_id')::UUID; + + DELETE FROM auth.sessions + WHERE user_id = v_user_id + AND id != COALESCE(v_current_session_id, '00000000-0000-0000-0000-000000000000'::UUID); + + GET DIAGNOSTICS v_revoked_count = ROW_COUNT; + RETURN v_revoked_count; +END; +$$; + +-- RPC to check whether the current session is still valid. +-- Returns FALSE if the session has been revoked or the user is not authenticated. +-- Used by the client to detect cross-device session revocation. +CREATE OR REPLACE FUNCTION public.is_session_valid() +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_current_session_id UUID; +BEGIN + IF auth.uid() IS NULL THEN + RETURN FALSE; + END IF; + + v_current_session_id := (current_setting('request.jwt.claims', true)::json ->> 'session_id')::UUID; + + RETURN EXISTS ( + SELECT 1 FROM auth.sessions + WHERE id = v_current_session_id AND user_id = auth.uid() + ); +END; +$$; + +-- ============================================ +-- Privileges: revoke default grants, then grant authenticated-only access +-- ============================================ + +REVOKE ALL ON FUNCTION public.get_active_sessions() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_active_sessions() FROM anon; +REVOKE ALL ON FUNCTION public.revoke_session(UUID) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.revoke_session(UUID) FROM anon; +REVOKE ALL ON FUNCTION public.revoke_other_sessions() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.revoke_other_sessions() FROM anon; +REVOKE ALL ON FUNCTION public.is_session_valid() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.is_session_valid() FROM anon; + +GRANT EXECUTE ON FUNCTION public.get_active_sessions() TO authenticated; +GRANT EXECUTE ON FUNCTION public.revoke_session(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION public.revoke_other_sessions() TO authenticated; +GRANT EXECUTE ON FUNCTION public.is_session_valid() TO authenticated; diff --git a/vite.config.ts b/vite.config.ts index 548c4ed..0d52446 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,7 @@ export default defineConfig(({ mode }) => { }, }, build: { + chunkSizeWarningLimit: 600, rollupOptions: { output: { manualChunks(id) {