From f9330b0d4dbfbc46aca5a677f7140994e8546902 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 9 Jul 2026 15:03:36 +0200 Subject: [PATCH 1/5] fix: redirect to login on cross-tab sign-out and scope scrollbar-gutter only to public routes --- src/app/Providers.tsx | 6 +- src/app/layouts/PublicLayout.tsx | 2 +- src/app/styles/globals.css | 1 - src/features/auth/model/auth-service.test.ts | 81 +++++++++++++++++++- src/features/auth/model/auth-service.ts | 10 ++- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx index b470c32..a36013c 100644 --- a/src/app/Providers.tsx +++ b/src/app/Providers.tsx @@ -28,11 +28,13 @@ function InnerApp() { useEffect(() => { restoreSession() - const unsubscribe = subscribeToAuthChanges() + const unsubscribe = subscribeToAuthChanges(() => { + router.navigate({ to: '/login' }) + }) return () => { unsubscribe() } - }, []) + }, [router]) if (auth.isRestoringSession) { return diff --git a/src/app/layouts/PublicLayout.tsx b/src/app/layouts/PublicLayout.tsx index c6c5ed7..3964e54 100644 --- a/src/app/layouts/PublicLayout.tsx +++ b/src/app/layouts/PublicLayout.tsx @@ -3,7 +3,7 @@ import { PublicHeader } from '@/shared/ui/nav/PublicHeader' function PublicLayout() { return ( -
+
diff --git a/src/app/styles/globals.css b/src/app/styles/globals.css index 567e74e..84dade2 100644 --- a/src/app/styles/globals.css +++ b/src/app/styles/globals.css @@ -146,7 +146,6 @@ } html { @apply font-sans; - scrollbar-gutter: stable; } button, a, diff --git a/src/features/auth/model/auth-service.test.ts b/src/features/auth/model/auth-service.test.ts index d783b30..78d63e8 100644 --- a/src/features/auth/model/auth-service.test.ts +++ b/src/features/auth/model/auth-service.test.ts @@ -500,7 +500,7 @@ describe('subscribeToAuthChanges', () => { const mockUnsubscribe = vi.fn() vi.mocked(authAdapter.onAuthStateChange).mockReturnValue(mockUnsubscribe) - const unsubscribe = subscribeToAuthChanges() + const unsubscribe = subscribeToAuthChanges(vi.fn()) expect(authAdapter.onAuthStateChange).toHaveBeenCalled() unsubscribe() expect(mockUnsubscribe).toHaveBeenCalled() @@ -513,7 +513,7 @@ describe('subscribeToAuthChanges', () => { return vi.fn() }) - subscribeToAuthChanges() + subscribeToAuthChanges(vi.fn()) const authResult = { user: { id: '2', username: 'callbackuser', createdAt: '' }, @@ -534,13 +534,88 @@ describe('subscribeToAuthChanges', () => { return vi.fn() }) - subscribeToAuthChanges() + subscribeToAuthChanges(vi.fn()) capturedCallback!(null) expect(mockClearVault).toHaveBeenCalled() expect(mockReset).toHaveBeenCalled() expect(terminateWorker).toHaveBeenCalled() }) + + it('calls onSignOut callback after clearing state when user was authenticated', () => { + let capturedCallback: ((result: unknown) => void) | undefined + vi.mocked(authAdapter.onAuthStateChange).mockImplementation((cb) => { + capturedCallback = cb as (result: unknown) => void + return vi.fn() + }) + + vi.mocked(useAuthStore.getState).mockReturnValue({ + user: { id: '1', username: 'test', createdAt: '' }, + session: { accessToken: 'at', expiresAt: 0 }, + isLoading: false, + setUser: vi.fn(), + setSession: vi.fn(), + setLoading: mockSetLoading, + setAuth: mockSetAuth, + setRestoringSession: mockSetRestoringSession, + reset: mockReset, + isRestoringSession: false, + }) + const onSignOut = vi.fn() + subscribeToAuthChanges(onSignOut) + capturedCallback!(null) + + expect(mockClearVault).toHaveBeenCalled() + expect(mockReset).toHaveBeenCalled() + expect(terminateWorker).toHaveBeenCalled() + expect(onSignOut).toHaveBeenCalled() + }) + + it('does not call onSignOut when user was not authenticated', () => { + let capturedCallback: ((result: unknown) => void) | undefined + vi.mocked(authAdapter.onAuthStateChange).mockImplementation((cb) => { + capturedCallback = cb as (result: unknown) => void + return vi.fn() + }) + + vi.mocked(useAuthStore.getState).mockReturnValue({ + user: null, + session: null, + isLoading: false, + setUser: vi.fn(), + setSession: vi.fn(), + setLoading: mockSetLoading, + setAuth: mockSetAuth, + setRestoringSession: mockSetRestoringSession, + reset: mockReset, + isRestoringSession: false, + }) + const onSignOut = vi.fn() + subscribeToAuthChanges(onSignOut) + capturedCallback!(null) + + expect(mockClearVault).toHaveBeenCalled() + expect(mockReset).toHaveBeenCalled() + expect(terminateWorker).toHaveBeenCalled() + expect(onSignOut).not.toHaveBeenCalled() + }) + + it('does not call onSignOut callback on auth result', () => { + let capturedCallback: ((result: unknown) => void) | undefined + vi.mocked(authAdapter.onAuthStateChange).mockImplementation((cb) => { + capturedCallback = cb as (result: unknown) => void + return vi.fn() + }) + + const onSignOut = vi.fn() + subscribeToAuthChanges(onSignOut) + capturedCallback!({ + user: { id: '3', username: 'test', createdAt: '' }, + session: { accessToken: 'tok', expiresAt: 0 }, + }) + + expect(onSignOut).not.toHaveBeenCalled() + }) }) describe('changeUserPassword', () => { diff --git a/src/features/auth/model/auth-service.ts b/src/features/auth/model/auth-service.ts index 52f4a08..7f1db82 100644 --- a/src/features/auth/model/auth-service.ts +++ b/src/features/auth/model/auth-service.ts @@ -233,14 +233,22 @@ export async function deleteUserAccount(password: string): Promise { * @remarks The underlying Supabase listener broadcasts auth events across * browser tabs, so a logout (or login) in one tab is reflected in all others. * + * @param onSignOut Callback invoked after local state is cleared on sign-out + * (e.g. to navigate to the login page). Called for both same-tab and + * cross-tab sign-outs. + * * @returns A function to unsubscribe from auth state changes. */ -export function subscribeToAuthChanges(): () => void { +export function subscribeToAuthChanges(onSignOut: () => void): () => void { return authAdapter.onAuthStateChange((result) => { if (result) { useAuthStore.getState().setAuth(result.user, result.session) } else { + const wasAuthenticated = useAuthStore.getState().user !== null logoutCleanup() + if (wasAuthenticated) { + onSignOut() + } } }) } From d2a850ca0241c72d28d89a7a79af5a8f18a67e8e Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 9 Jul 2026 17:50:59 +0200 Subject: [PATCH 2/5] feat: add session management with cross-device revocation and realtime updates --- src/app/layouts/ProtectedLayout.test.tsx | 15 + src/app/layouts/ProtectedLayout.tsx | 2 + src/app/router.test.tsx | 15 + src/features/auth/model/auth-service.ts | 5 + .../use-session-update-listener.test.tsx | 250 +++++++++++++++++ .../auth/model/use-session-update-listener.ts | 64 +++++ .../settings/lib/parse-user-agent.test.ts | 114 ++++++++ src/features/settings/lib/parse-user-agent.ts | 89 ++++++ src/features/settings/model/use-session.ts | 41 +++ src/features/settings/ui/SessionSection.tsx | 256 ++++++++++++++++++ .../settings/ui/SettingsPage.test.tsx | 2 +- src/features/settings/ui/SettingsPage.tsx | 2 + src/shared/api/supabase-session.test.ts | 124 +++++++++ src/shared/api/supabase-session.ts | 58 ++++ src/shared/auth/session-utils.test.ts | 40 +++ src/shared/auth/session-utils.ts | 20 ++ src/shared/crypto/vault/crypto-store.ts | 1 + src/shared/i18n/locales/cs/auth.json | 3 + src/shared/i18n/locales/cs/settings.json | 17 ++ src/shared/i18n/locales/en/auth.json | 3 + src/shared/i18n/locales/en/settings.json | 17 ++ src/shared/lib/query-keys.ts | 4 + src/shared/realtime/session-update.test.ts | 110 ++++++++ src/shared/realtime/session-update.ts | 63 +++++ src/shared/types/entities/user.types.ts | 10 + src/shared/types/supabase-schema.ts | 12 + .../migrations/00008_delete_account_rpc.sql | 2 +- supabase/migrations/00009_session_rpc.sql | 147 ++++++++++ vite.config.ts | 1 + 29 files changed, 1485 insertions(+), 2 deletions(-) create mode 100644 src/features/auth/model/use-session-update-listener.test.tsx create mode 100644 src/features/auth/model/use-session-update-listener.ts create mode 100644 src/features/settings/lib/parse-user-agent.test.ts create mode 100644 src/features/settings/lib/parse-user-agent.ts create mode 100644 src/features/settings/model/use-session.ts create mode 100644 src/features/settings/ui/SessionSection.tsx create mode 100644 src/shared/api/supabase-session.test.ts create mode 100644 src/shared/api/supabase-session.ts create mode 100644 src/shared/auth/session-utils.test.ts create mode 100644 src/shared/auth/session-utils.ts create mode 100644 src/shared/realtime/session-update.test.ts create mode 100644 src/shared/realtime/session-update.ts create mode 100644 supabase/migrations/00009_session_rpc.sql diff --git a/src/app/layouts/ProtectedLayout.test.tsx b/src/app/layouts/ProtectedLayout.test.tsx index 416db10..46b7203 100644 --- a/src/app/layouts/ProtectedLayout.test.tsx +++ b/src/app/layouts/ProtectedLayout.test.tsx @@ -41,6 +41,21 @@ vi.mock('@/shared/realtime/supabase-realtime', () => ({ realtimeAdapter: { subscribe: vi.fn(() => Promise.resolve()), unsubscribe: vi.fn() }, })) +// Session update broadcast is a network side-effect; stub the channel so +// rendering the layout in tests never opens a real channel. +vi.mock('@/shared/realtime/session-update', () => ({ + sessionUpdateChannel: { subscribe: vi.fn(), unsubscribe: vi.fn(), broadcastUpdate: vi.fn() }, +})) + +// The session revocation listener checks session validity on mount; stub it +// so tests don't make a real RPC call. +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 { useNavigationBlocker } from '@/features/fields/model/use-navigation-blocker' import { useBlocker } from '@tanstack/react-router' import { ProtectedLayout } from './ProtectedLayout' diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index 94a6836..2bfd749 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -20,6 +20,7 @@ import { useResizable } from '@/shared/lib/use-resizable' import { useVaultTimeout } from '@/features/vault/model/use-vault-timeout' import { useVaultVisibilityLock } from '@/features/vault/model/use-vault-visibility-lock' import { logoutUser } from '@/features/auth/model/auth-service' +import { useSessionUpdateListener } from '@/features/auth/model/use-session-update-listener' import { useRealtimeSync } from '@/features/fields/model/use-realtime-sync' import { useNavigationBlocker } from '@/features/fields/model/use-navigation-blocker' import { useAuth } from '@/shared/auth/auth-context' @@ -54,6 +55,7 @@ function AuthenticatedLayout() { useVaultTimeout() useVaultVisibilityLock() useRealtimeSync() + useSessionUpdateListener() useBlocker({ shouldBlockFn: () => !navigator.onLine, enableBeforeUnload: false, diff --git a/src/app/router.test.tsx b/src/app/router.test.tsx index 28d687e..cc7d64a 100644 --- a/src/app/router.test.tsx +++ b/src/app/router.test.tsx @@ -12,6 +12,21 @@ vi.mock('@/shared/realtime/supabase-realtime', () => ({ realtimeAdapter: { subscribe: vi.fn(() => Promise.resolve()), unsubscribe: vi.fn() }, })) +// The authenticated layout subscribes to session update broadcast; stub it +// so rendering the route tree in tests never opens a real channel. +vi.mock('@/shared/realtime/session-update', () => ({ + sessionUpdateChannel: { subscribe: vi.fn(), unsubscribe: vi.fn(), broadcastUpdate: vi.fn() }, +})) + +// The session revocation listener checks session validity on mount; stub it +// so the router tests don't make a real RPC call. +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)), +})) + // The entry detail route uses useEntryStatus which depends on useEntries; // stub it so the dashboard route renders field cards in tests. vi.mock('@/features/fields/model/use-entry-status', () => ({ diff --git a/src/features/auth/model/auth-service.ts b/src/features/auth/model/auth-service.ts index 7f1db82..576f2e7 100644 --- a/src/features/auth/model/auth-service.ts +++ b/src/features/auth/model/auth-service.ts @@ -10,6 +10,7 @@ import { deriveAuthCredentials } from '@/shared/crypto/keys/split-kdf' import { rewrapMasterKey } from '@/shared/crypto/keys/master-key' import { terminateWorker } from '@/shared/crypto/core/argon2id' import { keyVault } from '@/shared/crypto/vault/key-vault' +import { sessionUpdateChannel } from '@/shared/realtime/session-update' /** * Registers a new user: derives keys, signs up on the server, uploads encrypted @@ -56,6 +57,8 @@ export async function signUpUser(username: string, password: string): Promise { + const mockSubscribe = vi.fn<(userId: string, onUpdate: () => void) => void>() + const mockUnsubscribe = vi.fn<() => void>() + const mockIsSessionValid = vi.fn<() => Promise>() + const mockLogoutUser = vi.fn<() => Promise>() + const toastError = vi.fn<(msg: string, options?: unknown) => string | number>() + let onRevocationCallback: (() => void) | null = null + + // Auth store state — controllable per-test + const authState = { + user: { id: 'user-123', username: 'testuser' }, + isRestoringSession: false, + } + + return { + mockSubscribe, + mockUnsubscribe, + mockIsSessionValid, + mockLogoutUser, + toastError, + authState, + get onRevocationCallback() { + return onRevocationCallback + }, + setOnRevocationCallback(cb: (() => void) | null) { + onRevocationCallback = cb + }, + } +}) + +vi.mock('@/shared/realtime/session-update', () => ({ + sessionUpdateChannel: { + subscribe: ctx.mockSubscribe, + unsubscribe: ctx.mockUnsubscribe, + }, +})) + +vi.mock('@/shared/api/supabase-session', () => ({ + isSessionValid: ctx.mockIsSessionValid, +})) + +vi.mock('@/features/auth/model/auth-service', () => ({ + logoutUser: ctx.mockLogoutUser, +})) + +vi.mock('@/shared/auth/use-current-user', () => ({ + useRequiredUserId: () => 'user-123', +})) + +vi.mock('sonner', () => ({ + toast: { error: ctx.toastError }, +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const mockInvalidateQueries = vi.fn<() => Promise>().mockResolvedValue(undefined) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), + } +}) + +// Auth store mock — uses shared mutable state so per-test changes are visible +vi.mock('@/features/auth/model/auth-store', () => { + function useAuthStore(selector: (s: Record) => unknown) { + return selector(ctx.authState) + } + useAuthStore.getState = () => ctx.authState + const isAuthenticated = (state: { user: unknown }) => state.user !== null + return { useAuthStore, isAuthenticated } +}) + +// --- Import after mocks --- + +import { useSessionUpdateListener } from '@/features/auth/model/use-session-update-listener' + +describe('useSessionUpdateListener', () => { + let addEventListenerSpy: ReturnType + let removeEventListenerSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + ctx.setOnRevocationCallback(null) + + ctx.mockSubscribe.mockImplementation((_userId, onUpdate) => { + ctx.setOnRevocationCallback(onUpdate) + }) + + ctx.mockIsSessionValid.mockResolvedValue(true) + mockInvalidateQueries.mockResolvedValue(undefined) + + // Reset auth state to defaults + ctx.authState.user = { id: 'user-123', username: 'testuser' } + ctx.authState.isRestoringSession = false + + addEventListenerSpy = vi.spyOn(window, 'addEventListener') + removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('subscribes to the session update channel on mount', () => { + renderHook(() => useSessionUpdateListener()) + + expect(ctx.mockSubscribe).toHaveBeenCalledWith('user-123', expect.any(Function)) + }) + + it('calls checkSessionValidity on mount', () => { + renderHook(() => useSessionUpdateListener()) + + expect(ctx.mockIsSessionValid).toHaveBeenCalledTimes(1) + }) + + it('registers an online event listener on mount', () => { + renderHook(() => useSessionUpdateListener()) + + expect(addEventListenerSpy).toHaveBeenCalledWith('online', expect.any(Function)) + }) + + it('unsubscribes from the channel and removes online listener on unmount', () => { + const { unmount } = renderHook(() => useSessionUpdateListener()) + + unmount() + + expect(ctx.mockUnsubscribe).toHaveBeenCalledTimes(1) + expect(removeEventListenerSpy).toHaveBeenCalledWith('online', expect.any(Function)) + }) + + it('does not subscribe when isRestoringSession is true', () => { + ctx.authState.isRestoringSession = true + + renderHook(() => useSessionUpdateListener()) + + expect(ctx.mockSubscribe).not.toHaveBeenCalled() + expect(ctx.mockIsSessionValid).not.toHaveBeenCalled() + }) + + it('force-logouts when isSessionValid returns false (mount trigger)', async () => { + ctx.mockIsSessionValid.mockResolvedValue(false) + + renderHook(() => useSessionUpdateListener()) + + await vi.waitFor(() => { + expect(ctx.mockLogoutUser).toHaveBeenCalledTimes(1) + }) + + expect(ctx.toastError).toHaveBeenCalledWith('session.revokedElsewhere') + }) + + it('invalidates session query when isSessionValid returns true', async () => { + ctx.mockIsSessionValid.mockResolvedValue(true) + + renderHook(() => useSessionUpdateListener()) + + await vi.waitFor(() => { + expect(mockInvalidateQueries).toHaveBeenCalledTimes(1) + }) + + expect(ctx.mockLogoutUser).not.toHaveBeenCalled() + }) + + it('force-logouts when isSessionValid returns false (online event trigger)', async () => { + ctx.mockIsSessionValid + .mockResolvedValueOnce(true) // mount check + .mockResolvedValueOnce(false) // online event check + + renderHook(() => useSessionUpdateListener()) + + // Wait for mount check + await vi.waitFor(() => { + expect(ctx.mockIsSessionValid).toHaveBeenCalledTimes(1) + }) + + // Find and trigger the online listener + const onlineListener = addEventListenerSpy.mock.calls.find((call: unknown[]) => call[0] === 'online')?.[1] as + | (() => void) + | undefined + + expect(onlineListener).toBeDefined() + onlineListener!() + + await vi.waitFor(() => { + expect(ctx.mockLogoutUser).toHaveBeenCalledTimes(1) + }) + + expect(ctx.toastError).toHaveBeenCalledWith('session.revokedElsewhere') + }) + + it('force-logouts when broadcast callback fires and session is invalid', async () => { + ctx.mockIsSessionValid + .mockResolvedValueOnce(true) // mount check + .mockResolvedValueOnce(false) // broadcast check + + renderHook(() => useSessionUpdateListener()) + + await vi.waitFor(() => { + expect(ctx.mockIsSessionValid).toHaveBeenCalledTimes(1) + }) + + // Trigger the broadcast callback + expect(ctx.onRevocationCallback).not.toBeNull() + ctx.onRevocationCallback!() + + await vi.waitFor(() => { + expect(ctx.mockLogoutUser).toHaveBeenCalledTimes(1) + }) + + expect(ctx.toastError).toHaveBeenCalledWith('session.revokedElsewhere') + }) + + it('stays logged in when isSessionValid returns true (revoking client)', async () => { + ctx.mockIsSessionValid.mockResolvedValue(true) + + renderHook(() => useSessionUpdateListener()) + + await vi.waitFor(() => { + expect(ctx.mockIsSessionValid).toHaveBeenCalledTimes(1) + }) + + expect(ctx.mockLogoutUser).not.toHaveBeenCalled() + expect(ctx.toastError).not.toHaveBeenCalled() + }) + + it('does not force-logout on network error (silently skips)', async () => { + ctx.mockIsSessionValid.mockRejectedValue(new TypeError('Failed to fetch')) + + renderHook(() => useSessionUpdateListener()) + + await vi.waitFor(() => { + expect(ctx.mockIsSessionValid).toHaveBeenCalledTimes(1) + }) + + expect(ctx.mockLogoutUser).not.toHaveBeenCalled() + expect(ctx.toastError).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/auth/model/use-session-update-listener.ts b/src/features/auth/model/use-session-update-listener.ts new file mode 100644 index 0000000..df5b01b --- /dev/null +++ b/src/features/auth/model/use-session-update-listener.ts @@ -0,0 +1,64 @@ +import { useEffect, useCallback } from 'react' +import { toast } from 'sonner' +import { useTranslation } from 'react-i18next' +import { useQueryClient } from '@tanstack/react-query' + +import { useRequiredUserId } from '@/shared/auth/use-current-user' +import { useAuthStore, isAuthenticated as isAuthenticatedSelector } from '@/features/auth/model/auth-store' +import { sessionUpdateChannel } from '@/shared/realtime/session-update' +import { isSessionValid } from '@/shared/api/supabase-session' +import { logoutUser } from '@/features/auth/model/auth-service' +import { queryKeys } from '@/shared/lib/query-keys' + +/** + * Reacts to cross-device session changes by checking validity. + * + * Three triggers call `checkSessionValidity`: + * 1. **Realtime broadcast** — another device added/revoked a session. + * Revoked → force-logout with toast; valid → refresh session list. + * 2. **Online event** — catches changes missed while offline. + * 3. **Mount check** — catches revocation after app reopen. + */ +function useSessionUpdateListener(): void { + const userId = useRequiredUserId() + const isRestoringSession = useAuthStore((s) => s.isRestoringSession) + const queryClient = useQueryClient() + const { t } = useTranslation('auth') + + const checkSessionValidity = useCallback(async () => { + try { + const valid = await isSessionValid() + if (!valid) { + toast.error(t('session.revokedElsewhere')) + void logoutUser() + } else { + // Session is valid, but the list may have changed — refresh it + await queryClient.invalidateQueries({ queryKey: queryKeys.session.list }) + } + } catch { + // Network error — don't force-logout; next trigger will retry + } + }, [t, queryClient]) + + useEffect(() => { + if (!isAuthenticatedSelector(useAuthStore.getState()) || isRestoringSession) return + + // Realtime broadcast: subscribe to session update channel + sessionUpdateChannel.subscribe(userId, () => { + void checkSessionValidity() + }) + + // Mount check: catch revoked session after app reopen + void checkSessionValidity() + + // Online event: catch revoked session after offline period + window.addEventListener('online', checkSessionValidity) + + return () => { + sessionUpdateChannel.unsubscribe() + window.removeEventListener('online', checkSessionValidity) + } + }, [userId, isRestoringSession, checkSessionValidity]) +} + +export { useSessionUpdateListener } diff --git a/src/features/settings/lib/parse-user-agent.test.ts b/src/features/settings/lib/parse-user-agent.test.ts new file mode 100644 index 0000000..b827d3d --- /dev/null +++ b/src/features/settings/lib/parse-user-agent.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest' +import { parseUserAgent, formatIP } from '@/features/settings/lib/parse-user-agent' + +describe('parseUserAgent', () => { + it('returns Unknown values for null input', () => { + const result = parseUserAgent(null) + expect(result).toEqual({ browser: 'Unknown', os: 'Unknown', deviceType: 'unknown' }) + }) + + it('parses Chrome on Windows', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' + const result = parseUserAgent(ua) + expect(result.browser).toBe('Chrome') + expect(result.os).toBe('Windows') + expect(result.deviceType).toBe('desktop') + }) + + it('parses Safari on macOS', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15' + const result = parseUserAgent(ua) + expect(result.browser).toBe('Safari') + expect(result.os).toBe('macOS') + expect(result.deviceType).toBe('desktop') + }) + + it('parses Chrome on Android mobile', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.71 Mobile Safari/537.36' + const result = parseUserAgent(ua) + expect(result.browser).toBe('Chrome') + expect(result.os).toBe('Android') + expect(result.deviceType).toBe('mobile') + }) + + it('parses Safari on iOS', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1' + const result = parseUserAgent(ua) + expect(result.browser).toBe('Safari') + expect(result.os).toBe('iOS') + expect(result.deviceType).toBe('mobile') + }) + + it('parses Edge on Windows (Edge must match before Chrome)', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0' + const result = parseUserAgent(ua) + expect(result.browser).toBe('Edge') + expect(result.os).toBe('Windows') + expect(result.deviceType).toBe('desktop') + }) + + it('parses Firefox on Linux', () => { + const ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0' + const result = parseUserAgent(ua) + expect(result.browser).toBe('Firefox') + expect(result.os).toBe('Linux') + expect(result.deviceType).toBe('desktop') + }) + + it('detects iPad as tablet', () => { + const ua = + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1' + const result = parseUserAgent(ua) + expect(result.os).toBe('iPadOS') + expect(result.deviceType).toBe('tablet') + }) + + it('detects ChromeOS', () => { + const ua = + 'Mozilla/5.0 (X11; CrOS x86_64 14526.89.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.71 Safari/537.36' + const result = parseUserAgent(ua) + expect(result.os).toBe('ChromeOS') + expect(result.deviceType).toBe('desktop') + }) + + it('detects Opera', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 OPR/112.0.0.0' + const result = parseUserAgent(ua) + expect(result.browser).toBe('Opera') + }) + + it('returns unknown for unrecognizable UA strings', () => { + const result = parseUserAgent('SomeBot/1.0') + expect(result.browser).toBe('Unknown') + expect(result.os).toBe('Unknown') + expect(result.deviceType).toBe('unknown') + }) +}) + +describe('formatIP', () => { + it('masks the last octet of IPv4 addresses', () => { + expect(formatIP('192.168.1.42')).toBe('192.168.1.*') + }) + + it('truncates long IPv6 addresses', () => { + expect(formatIP('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe('2001:0db8:85a3:0000::*') + }) + + it('returns short IPv6 addresses as-is', () => { + expect(formatIP('::1')).toBe('::1') + }) + + it('returns — for null', () => { + expect(formatIP(null)).toBe('—') + }) + + it('returns non-IP strings as-is', () => { + expect(formatIP('unknown')).toBe('unknown') + }) +}) diff --git a/src/features/settings/lib/parse-user-agent.ts b/src/features/settings/lib/parse-user-agent.ts new file mode 100644 index 0000000..e0e11e1 --- /dev/null +++ b/src/features/settings/lib/parse-user-agent.ts @@ -0,0 +1,89 @@ +// Consider replacing with the 'lightua' npm package for broader coverage if needed. + +export interface ParsedUserAgent { + browser: string + os: string + deviceType: 'desktop' | 'mobile' | 'tablet' | 'unknown' +} + +const BROWSER_PATTERNS: Array<{ pattern: RegExp; name: string }> = [ + { pattern: /Edg\/([\d.]+)/i, name: 'Edge' }, + { pattern: /OPR\/([\d.]+)/i, name: 'Opera' }, + { pattern: /Chrome\/([\d.]+)/i, name: 'Chrome' }, + { pattern: /Firefox\/([\d.]+)/i, name: 'Firefox' }, + { pattern: /Safari\/([\d.]+)/i, name: 'Safari' }, +] + +const OS_PATTERNS: Array<{ pattern: RegExp; name: string }> = [ + { pattern: /Windows NT\s([\d.]+)/i, name: 'Windows' }, + { pattern: /Mac OS X\s([\d._]+)/i, name: 'macOS' }, + { pattern: /Android\s([\d.]+)/i, name: 'Android' }, + { pattern: /iPhone OS\s([\d_]+)/i, name: 'iOS' }, + { pattern: /iPad.*OS\s([\d_]+)/i, name: 'iPadOS' }, + { pattern: /CrOS/i, name: 'ChromeOS' }, + { pattern: /Linux/i, name: 'Linux' }, +] + +function detectDeviceType(userAgent: string): 'desktop' | 'mobile' | 'tablet' | 'unknown' { + const ua = userAgent.toLowerCase() + + if (/tablet|ipad|android(?!.*mobile)/i.test(ua)) return 'tablet' + if (/mobile|iphone|ipod/i.test(ua)) return 'mobile' + if (/windows|macintosh|linux|cros/i.test(ua)) return 'desktop' + + return 'unknown' +} + +/** + * Parse a User-Agent string into browser, OS, and device type. + * Returns "Unknown" values when the UA is null or unrecognized. + */ +export function parseUserAgent(userAgent: string | null): ParsedUserAgent { + if (!userAgent) { + return { browser: 'Unknown', os: 'Unknown', deviceType: 'unknown' } + } + + let browser = 'Unknown' + for (const { pattern, name } of BROWSER_PATTERNS) { + if (pattern.test(userAgent)) { + browser = name + break + } + } + + let os = 'Unknown' + for (const { pattern, name } of OS_PATTERNS) { + if (pattern.test(userAgent)) { + os = name + break + } + } + + return { + browser, + os, + deviceType: detectDeviceType(userAgent), + } +} + +/** + * Format an IP address for display, masking the last octet of IPv4 + * and truncating IPv6 for privacy. + */ +export function formatIP(ip: string | null): string { + if (!ip) return '—' + + // IPv4: mask last octet + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) { + const parts = ip.split('.') + return `${parts[0]}.${parts[1]}.${parts[2]}.*` + } + + // IPv6: show first 4 segments + if (ip.includes(':')) { + const segments = ip.split(':') + return segments.length > 4 ? `${segments.slice(0, 4).join(':')}::*` : ip + } + + return ip +} diff --git a/src/features/settings/model/use-session.ts b/src/features/settings/model/use-session.ts new file mode 100644 index 0000000..d809094 --- /dev/null +++ b/src/features/settings/model/use-session.ts @@ -0,0 +1,41 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' + +import { getActiveSessions, revokeSession, revokeOtherSessions } from '@/shared/api/supabase-session' +import { queryKeys } from '@/shared/lib/query-keys' +import { sessionUpdateChannel } from '@/shared/realtime/session-update' +import { useAuthStore } from '@/features/auth/model/auth-store' + +/** Fetch all active sessions for the current user. */ +export function useActiveSessions() { + return useQuery({ + queryKey: queryKeys.session.list, + queryFn: getActiveSessions, + }) +} + +/** Invalidate the session list query and broadcast the change to other devices. */ +function onSessionMutated(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: queryKeys.session.list }) + const userId = useAuthStore.getState().user?.id + if (userId) sessionUpdateChannel.broadcastUpdate(userId) +} + +/** Revoke a specific session. */ +export function useRevokeSession() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: revokeSession, + onSuccess: () => onSessionMutated(queryClient), + }) +} + +/** Revoke all sessions except the current one. */ +export function useRevokeOtherSessions() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: revokeOtherSessions, + onSuccess: () => onSessionMutated(queryClient), + }) +} diff --git a/src/features/settings/ui/SessionSection.tsx b/src/features/settings/ui/SessionSection.tsx new file mode 100644 index 0000000..410d4a8 --- /dev/null +++ b/src/features/settings/ui/SessionSection.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Monitor, Smartphone, Tablet, LogOut, ShieldCheck } 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, formatIP } from '@/features/settings/lib/parse-user-agent' +import { useActiveSessions, useRevokeSession, useRevokeOtherSessions } from '@/features/settings/model/use-session' + +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString) + const now = Date.now() + const diffMs = now - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 1) return 'just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + return date.toLocaleDateString() +} + +function SessionRow({ + sessionId, + userAgent, + ip, + updatedAt, + isCurrent, + isRevoking, + onRevoke, +}: { + sessionId: string + userAgent: string | null + ip: string | null + updatedAt: string + isCurrent: boolean + isRevoking: boolean + onRevoke: () => void +}) { + const { t } = useTranslation('settings') + const parsed = parseUserAgent(userAgent) + + return ( +
+
+ {parsed.deviceType === 'mobile' ? ( + + ) : parsed.deviceType === 'tablet' ? ( + + ) : ( + + )} +
+
+ {parsed.browser} + {isCurrent && ( + + + {t('session.currentDevice')} + + )} +
+
+ {parsed.os} · {formatIP(ip)} · {t('session.lastActive', { time: formatRelativeTime(updatedAt) })} +
+
+
+ {isCurrent ? ( + {t('session.currentDevice')} + ) : ( + + )} +
+ ) +} + +function SessionSection() { + const { t } = useTranslation('settings') + const [revokeTargetId, setRevokeTargetId] = useState(null) + const [showRevokeAllDialog, setShowRevokeAllDialog] = useState(false) + + 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 handleRevokeSession(sessionId: string) { + setRevokeTargetId(sessionId) + } + + function confirmRevokeSession() { + if (!revokeTargetId) return + revokeSessionMutation.mutate(revokeTargetId, { + onSuccess: (deleted) => { + if (deleted) { + toast.success(t('session.revokeSuccess')) + } else { + toast.error(t('session.revokeFailed')) + } + }, + onError: () => { + toast.error(t('session.revokeFailed')) + }, + onSettled: () => { + setRevokeTargetId(null) + }, + }) + } + + function confirmRevokeAll() { + revokeOtherSessionsMutation.mutate(undefined, { + onSuccess: (count) => { + toast.success(t('session.revokeAllSuccess', { count })) + }, + onError: () => { + toast.error(t('session.revokeAllFailed')) + }, + onSettled: () => { + setShowRevokeAllDialog(false) + }, + }) + } + + const revokeTarget = (sessions ?? []).find((s) => s.id === revokeTargetId) + 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 && } + handleRevokeSession(session.id)} + /> +
+ ))} + {otherSessions.length > 0 && ( + <> + +
+ {t('session.revokeAll')} + +
+ + )} + + )} +
+
+ + !open && setRevokeTargetId(null)}> + + + {t('session.revokeConfirmTitle')} + + {t('session.revokeConfirmBody', { + device: revokeTargetInfo ? `${revokeTargetInfo.browser} on ${revokeTargetInfo.os}` : 'Unknown', + })} + + + + {t('common:actions.cancel')} + + {t('session.revoke')} + + + + + + + + + {t('session.revokeAllConfirmTitle')} + {t('session.revokeAllConfirmBody')} + + + {t('common:actions.cancel')} + + {t('session.revokeAll')} + + + + + + ) +} + +export { SessionSection } diff --git a/src/features/settings/ui/SettingsPage.test.tsx b/src/features/settings/ui/SettingsPage.test.tsx index b76ddc7..676c2cc 100644 --- a/src/features/settings/ui/SettingsPage.test.tsx +++ b/src/features/settings/ui/SettingsPage.test.tsx @@ -22,7 +22,7 @@ describe('SettingsPage', () => { const { container } = render() 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', 'Sessions', 'About']) }) 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..adccdaa 100644 --- a/src/features/settings/ui/SettingsPage.tsx +++ b/src/features/settings/ui/SettingsPage.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { AccountSection } from '@/features/settings/ui/AccountSection' import { PreferencesSection } from '@/features/settings/ui/PreferencesSection' import { SecuritySection } from '@/features/settings/ui/SecuritySection' +import { SessionSection } from '@/features/settings/ui/SessionSection' import { AboutSection } from '@/features/settings/ui/AboutSection' function SettingsPage() { @@ -15,6 +16,7 @@ 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/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/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/settings.json b/src/shared/i18n/locales/cs/settings.json index ce5dade..e9d2488 100644 --- a/src/shared/i18n/locales/cs/settings.json +++ b/src/shared/i18n/locales/cs/settings.json @@ -34,6 +34,23 @@ "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 ostatní", + "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?", + "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?", + "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/settings.json b/src/shared/i18n/locales/en/settings.json index fb8d3f6..e816333 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -34,6 +34,23 @@ "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?", + "revokeConfirmBody": "This will sign out {{device}}. Any unsaved changes on that device will be lost.", + "revokeAllConfirmTitle": "Revoke all other sessions?", + "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/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) { From 4e5230efb1f1f9abdc2ab84240c23124c492e591 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 9 Jul 2026 19:39:29 +0200 Subject: [PATCH 3/5] feat: use local signout scope and unify session revoke dialogs --- src/app/layouts/ProtectedLayout.tsx | 6 +- src/features/auth/model/auth-service.test.ts | 81 ++++++++ src/features/auth/model/auth-service.ts | 3 + src/features/settings/ui/SessionRow.test.tsx | 101 +++++++++ src/features/settings/ui/SessionRow.tsx | 94 +++++++++ src/features/settings/ui/SessionSection.tsx | 195 +++++------------- .../settings/ui/SettingsPage.test.tsx | 6 +- src/features/settings/ui/SettingsPage.tsx | 13 +- src/shared/auth/supabase-adapter.test.ts | 30 +++ src/shared/auth/supabase-adapter.ts | 2 +- src/shared/i18n/locales/cs/settings.json | 4 +- src/shared/i18n/locales/en/settings.json | 2 + src/shared/ui/CardSkeleton.tsx | 32 +++ 13 files changed, 418 insertions(+), 151 deletions(-) create mode 100644 src/features/settings/ui/SessionRow.test.tsx create mode 100644 src/features/settings/ui/SessionRow.tsx create mode 100644 src/shared/ui/CardSkeleton.tsx diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index 2bfd749..6558f9c 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -64,7 +64,7 @@ function AuthenticatedLayout() { useNavigationBlocker() return ( -
+
{/* Desktop sidebar */}
+
+ }> + + +
) } 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/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json index e9d2488..6e1c6fa 100644 --- a/src/shared/i18n/locales/cs/settings.json +++ b/src/shared/i18n/locales/cs/settings.json @@ -41,14 +41,16 @@ "lastActive": "Poslední aktivita: {{time}}", "noSessions": "Žádné aktivní relace", "revoke": "Odhlásit", - "revokeAll": "Odhlásit všechny ostatní", + "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": { diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index e816333..0e29c95 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -47,8 +47,10 @@ "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": { 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 } From 941eea0c3e47ad94671d61c0fff8ab84b39fa0ae Mon Sep 17 00:00:00 2001 From: VitekHub Date: Thu, 9 Jul 2026 20:17:13 +0200 Subject: [PATCH 4/5] feat: add session management E2E tests --- e2e/sessions.spec.ts | 126 +++++++++++++++++++ src/features/settings/ui/SessionRow.test.tsx | 2 +- src/features/settings/ui/SessionRow.tsx | 7 +- 3 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 e2e/sessions.spec.ts 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
From 646f352122ab1eb8940b63ba0cecebe562ea6f95 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Fri, 10 Jul 2026 16:44:04 +0200 Subject: [PATCH 5/5] feat: bump version to 1.1.0 and improve test reliability --- package.json | 2 +- src/app/router.test.tsx | 40 +++++++++++------- .../auth/model/use-session-update-listener.ts | 11 +++-- .../settings/ui/SecuritySection.test.tsx | 8 +++- src/features/settings/ui/SessionRow.tsx | 42 ++++++++++++++++--- .../settings/ui/SettingsPage.test.tsx | 14 ++++++- src/shared/i18n/config.test.ts | 4 +- src/shared/i18n/locales/cs/common.json | 2 +- src/shared/i18n/locales/en/common.json | 2 +- src/test/setup.ts | 1 + src/test/utils.tsx | 6 ++- 11 files changed, 99 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index c0e7ea3..9c9e137 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cipher-note", "private": true, - "version": "1.0.0", + "version": "1.1.0", "packageManager": "pnpm@9.6.0", "type": "module", "scripts": { diff --git a/src/app/router.test.tsx b/src/app/router.test.tsx index cc7d64a..67a28b8 100644 --- a/src/app/router.test.tsx +++ b/src/app/router.test.tsx @@ -112,33 +112,45 @@ describe('Router page rendering', () => { it('renders register page at /register', async () => { renderWithRouter({}, '/register') - await waitFor(() => { - expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument() - }) + await waitFor( + () => { + expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument() + }, + { timeout: 5000 }, + ) }) it('renders recover page at /recover', async () => { renderWithRouter({}, '/recover') - await waitFor(() => { - expect(screen.getByRole('button', { name: /recover/i })).toBeInTheDocument() - }) + await waitFor( + () => { + expect(screen.getByRole('button', { name: /recover/i })).toBeInTheDocument() + }, + { timeout: 5000 }, + ) }) it('renders dashboard with field cards when authenticated', async () => { useAuthStore.setState({ user: { id: '1', username: 'test', createdAt: '2024-01-01T00:00:00Z' } }) renderWithRouter({ isAuthenticated: true, user: { id: '1', username: 'test' } }, '/dashboard/test-entry') - await waitFor(() => { - expect(screen.getByText('Note')).toBeInTheDocument() - expect(screen.getByText('Website')).toBeInTheDocument() - expect(screen.getByText('Email')).toBeInTheDocument() - }) + await waitFor( + () => { + expect(screen.getByText('Note')).toBeInTheDocument() + expect(screen.getByText('Website')).toBeInTheDocument() + expect(screen.getByText('Email')).toBeInTheDocument() + }, + { timeout: 5000 }, + ) }) it('renders settings page when authenticated', async () => { useAuthStore.setState({ user: { id: '1', username: 'test', createdAt: '2024-01-01T00:00:00Z' } }) renderWithRouter({ isAuthenticated: true, user: { id: '1', username: 'test' } }, '/settings') - await waitFor(() => { - expect(screen.getAllByText(/account/i).length).toBeGreaterThan(0) - }) + await waitFor( + () => { + expect(screen.getAllByText(/account/i).length).toBeGreaterThan(0) + }, + { timeout: 5000 }, + ) }) }) diff --git a/src/features/auth/model/use-session-update-listener.ts b/src/features/auth/model/use-session-update-listener.ts index df5b01b..c01533e 100644 --- a/src/features/auth/model/use-session-update-listener.ts +++ b/src/features/auth/model/use-session-update-listener.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react' +import { useEffect, useCallback, useRef } from 'react' import { toast } from 'sonner' import { useTranslation } from 'react-i18next' import { useQueryClient } from '@tanstack/react-query' @@ -25,11 +25,16 @@ function useSessionUpdateListener(): void { const queryClient = useQueryClient() const { t } = useTranslation('auth') + const tRef = useRef(t) + useEffect(() => { + tRef.current = t + }, [t]) + const checkSessionValidity = useCallback(async () => { try { const valid = await isSessionValid() if (!valid) { - toast.error(t('session.revokedElsewhere')) + toast.error(tRef.current('session.revokedElsewhere')) void logoutUser() } else { // Session is valid, but the list may have changed — refresh it @@ -38,7 +43,7 @@ function useSessionUpdateListener(): void { } catch { // Network error — don't force-logout; next trigger will retry } - }, [t, queryClient]) + }, [queryClient]) useEffect(() => { if (!isAuthenticatedSelector(useAuthStore.getState()) || isRestoringSession) return diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index ccf522f..ac5616a 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -72,7 +72,9 @@ describe('SecuritySection', () => { expect(select).toHaveTextContent('15 min') await user.click(select) - expect(screen.getAllByRole('option')).toHaveLength(5) + // Options are rendered via a portal asynchronously + const options = await screen.findAllByRole('option') + expect(options).toHaveLength(5) }) it('updates vaultTimeoutMs when the auto-lock selection changes', async () => { @@ -81,7 +83,9 @@ describe('SecuritySection', () => { const select = screen.getByRole('combobox', { name: 'Auto-lock vault' }) await user.click(select) - await user.click(screen.getByRole('option', { name: '5 min' })) + // Options are rendered via a portal asynchronously + const fiveMinOption = await screen.findByRole('option', { name: '5 min' }) + await user.click(fiveMinOption) expect(useVaultSettingsStore.getState().vaultTimeoutMs).toBe(5 * 60 * 1000) }) diff --git a/src/features/settings/ui/SessionRow.tsx b/src/features/settings/ui/SessionRow.tsx index 7510422..de49448 100644 --- a/src/features/settings/ui/SessionRow.tsx +++ b/src/features/settings/ui/SessionRow.tsx @@ -10,6 +10,39 @@ const DEVICE_ICONS: Record = { tablet: Tablet, } +// Module-level caches for Intl formatters, keyed by language. +// Avoids constructing new formatter objects on every render. +const relativeTimeFormatters = new Map() +const dateTimeFormatters = new Map() +const fullDateFormatters = new Map() + +function getRelativeTimeFormatter(language: string): Intl.RelativeTimeFormat { + let rtf = relativeTimeFormatters.get(language) + if (!rtf) { + rtf = new Intl.RelativeTimeFormat(language, { numeric: 'auto' }) + relativeTimeFormatters.set(language, rtf) + } + return rtf +} + +function getDateTimeFormatter(language: string): Intl.DateTimeFormat { + let dtf = dateTimeFormatters.get(language) + if (!dtf) { + dtf = new Intl.DateTimeFormat(language, { dateStyle: 'medium' }) + dateTimeFormatters.set(language, dtf) + } + return dtf +} + +function getFullDateFormatter(language: string): Intl.DateTimeFormat { + let fdf = fullDateFormatters.get(language) + if (!fdf) { + fdf = new Intl.DateTimeFormat(language, { dateStyle: 'medium', timeStyle: 'short' }) + fullDateFormatters.set(language, fdf) + } + return fdf +} + function formatRelativeTime(dateString: string, language: string): string { const diffMs = Date.now() - new Date(dateString).getTime() const diffSecs = Math.floor(diffMs / 1000) @@ -17,20 +50,17 @@ function formatRelativeTime(dateString: string, language: string): string { const diffHours = Math.floor(diffMins / 60) const diffDays = Math.floor(diffHours / 24) - const rtf = new Intl.RelativeTimeFormat(language, { numeric: 'auto' }) + const rtf = getRelativeTimeFormatter(language) if (diffSecs < 60) return rtf.format(-diffSecs, 'second') if (diffMins < 60) return rtf.format(-diffMins, 'minute') if (diffHours < 24) return rtf.format(-diffHours, 'hour') if (diffDays < 7) return rtf.format(-diffDays, 'day') - return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(new Date(dateString)) + return getDateTimeFormatter(language).format(new Date(dateString)) } function formatFullDate(dateString: string, language: string): string { - return new Intl.DateTimeFormat(language, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(dateString)) + return getFullDateFormatter(language).format(new Date(dateString)) } function SessionRow({ diff --git a/src/features/settings/ui/SettingsPage.test.tsx b/src/features/settings/ui/SettingsPage.test.tsx index 7ab27d7..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', () => { @@ -21,7 +31,7 @@ describe('SettingsPage', () => { 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') + 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', 'Sessions']) 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/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/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/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 }) {