diff --git a/packages/react-components/src/components/TopNavigation.tsx b/packages/react-components/src/components/TopNavigation.tsx index b5bce26..b3e3696 100644 --- a/packages/react-components/src/components/TopNavigation.tsx +++ b/packages/react-components/src/components/TopNavigation.tsx @@ -7,11 +7,9 @@ import { JwtPayload, jwtDecode } from 'jwt-decode' import md5 from 'md5' import { useCell } from '../contexts/CellContext' -import { getSessionToken } from '../token' - -const getGravatarUrl = (size = 24) => { - const token = getSessionToken() +import { useMemo } from 'react' +const getGravatarUrl = (token: string | undefined, size = 96) => { if (!token || token === 'unauthenticated') { return 'unauthenticated' } @@ -35,36 +33,52 @@ const getGravatarUrl = (size = 24) => { } } -const UserAvatar = () => ( - - - - - - - - - - - - - - Logout - - - -) +const UserAvatar = ({ url, token }: { url?: string; token?: string }) => { + const src = useMemo(() => { + if (url) { + return url + } + return getGravatarUrl(token) + }, [url, token]) + + return ( + + + + + + + + + + + + + + Logout + + + + ) +} -const TopNavigation = ({ actions }: { actions?: React.ReactNode[] }) => { +const TopNavigation = ({ + actions, + avatar, +}: { + actions?: React.ReactNode[] + avatar?: React.ReactNode +}) => { const { resetSession, exportDocument } = useCell() const navigate = useNavigate() const location = useLocation() + return ( <> {actions?.map((button) => button)} @@ -160,9 +174,11 @@ const TopNavigation = ({ actions }: { actions?: React.ReactNode[] }) => { Settings - + {avatar && avatar} ) } +TopNavigation.UserAvatar = UserAvatar + export default TopNavigation diff --git a/packages/react-components/src/components/__tests__/TopNavigation.test.tsx b/packages/react-components/src/components/__tests__/TopNavigation.test.tsx index 67cc91f..dcd2120 100644 --- a/packages/react-components/src/components/__tests__/TopNavigation.test.tsx +++ b/packages/react-components/src/components/__tests__/TopNavigation.test.tsx @@ -50,7 +50,9 @@ describe('TopNavigation', () => { }) it('renders user avatar', () => { - render() + render( + } /> + ) // The avatar should be present (it's a span with avatar styling) const avatar = document.querySelector('span[class*="size-[24px]"]') expect(avatar).toBeInTheDocument() diff --git a/packages/react-components/src/contexts/CellContext.tsx b/packages/react-components/src/contexts/CellContext.tsx index 55084e0..b17c3f3 100644 --- a/packages/react-components/src/contexts/CellContext.tsx +++ b/packages/react-components/src/contexts/CellContext.tsx @@ -23,11 +23,18 @@ import { parser_pb, runner_pb, } from '../runme/client' -import { SessionStorage, generateSessionName } from '../storage' +import { + DexieSessionStorage, + generateSessionName, + type ISessionStorage, +} from '../storage' +import type { RunnerClient } from '../runme/client' import { areCellsSimilar } from '../simhash' import { useClient as useAgentClient } from './AgentContext' import { useOutput } from './OutputContext' import { useSettings } from './SettingsContext' +import { getSessionToken } from '../token' +import { jwtDecode, JwtPayload } from 'jwt-decode' type CellContextType = { // useColumns returns arrays of cells organized by their kind @@ -113,16 +120,34 @@ function getAscendingCells( return cells } +function getPrincipal(): string { + const token = getSessionToken() + if (!token) { + return 'unauthenticated' + } + let decodedToken: JwtPayload & { email?: string } + try { + decodedToken = jwtDecode(token) + return decodedToken.email || decodedToken.sub || 'unauthenticated' + } catch (e) { + console.error('Error decoding token', e) + return 'unauthenticated' + } +} + export interface CellProviderProps { children: ReactNode /** Function to obtain the access token string or promise thereof */ getAccessToken: () => string | Promise + /** Optional factory to create a custom session storage. Defaults to DexieSessionStorage. */ + createStorage?: (client: RunnerClient) => ISessionStorage } export const CellProvider = ({ children, getAccessToken, + createStorage, }: CellProviderProps) => { - const { settings, createAuthInterceptors, principal } = useSettings() + const { settings, createAuthInterceptors } = useSettings() const [sequence, setSequence] = useState(0) const [isInputDisabled, setIsInputDisabled] = useState(false) const [isTyping, setIsTyping] = useState(false) @@ -131,6 +156,8 @@ export const CellProvider = ({ >() const { getAllRenderers } = useOutput() + const principal = getPrincipal() + const runnerConnectEndpoint = useMemo(() => { const url = new URL(settings.webApp.runner) if (url.protocol === 'ws:') { @@ -144,19 +171,18 @@ export const CellProvider = ({ }, [settings.webApp.runner]) const storage = useMemo(() => { - if (!principal) { - return - } - return new SessionStorage( - 'agent', - principal, - createConnectClient( - runner_pb.RunnerService, - runnerConnectEndpoint, - createAuthInterceptors(true) - ) + const runnerClient = createConnectClient( + runner_pb.RunnerService, + runnerConnectEndpoint, + createAuthInterceptors(true) ) - }, [settings.agentEndpoint, principal]) + + // Use custom factory if provided, otherwise default to Dexie + if (createStorage) { + return createStorage(runnerClient) + } + return new DexieSessionStorage('agent', principal, runnerClient) + }, [runnerConnectEndpoint, createAuthInterceptors, createStorage]) const invertedOrder = useMemo( () => settings.webApp.invertedOrder, diff --git a/packages/react-components/src/contexts/SettingsContext.tsx b/packages/react-components/src/contexts/SettingsContext.tsx index 320e744..bc23c43 100644 --- a/packages/react-components/src/contexts/SettingsContext.tsx +++ b/packages/react-components/src/contexts/SettingsContext.tsx @@ -16,7 +16,6 @@ import { Streams, genRunID, } from '@runmedev/react-console' -import { JwtPayload, jwtDecode } from 'jwt-decode' import { Subscription } from 'rxjs' import { ulid } from 'ulid' @@ -30,7 +29,6 @@ interface Settings { } interface SettingsContextType { - principal: string checkRunnerAuth: () => void createAuthInterceptors: (redirect: boolean) => Interceptor[] defaultSettings: Settings @@ -72,21 +70,6 @@ export const SettingsProvider = ({ }: SettingsProviderProps) => { const [runnerError, setRunnerError] = useState(null) - const principal = useMemo(() => { - const token = getSessionToken() - if (!token) { - return 'unauthenticated' - } - let decodedToken: JwtPayload & { email?: string } - try { - decodedToken = jwtDecode(token) - return decodedToken.email || decodedToken.sub || 'unauthenticated' - } catch (e) { - console.error('Error decoding token', e) - return 'unauthenticated' - } - }, []) - const defaultSettings: Settings = useMemo(() => { const isLocalhost = window.location.hostname === 'localhost' const isHttp = window.location.protocol === 'http:' @@ -237,7 +220,6 @@ export const SettingsProvider = ({ return ( { const settings = useSettings() return (
- {settings.principal} {settings.settings.agentEndpoint} @@ -100,49 +99,6 @@ describe('SettingsContext', () => { expect(screen.getByTestId('require-auth')).toHaveTextContent('false') }) - it('handles unauthenticated user', async () => { - const { getSessionToken } = await import('../../token') - vi.mocked(getSessionToken).mockReturnValue(undefined) - - render( - - - - ) - - expect(screen.getByTestId('principal')).toHaveTextContent( - 'unauthenticated' - ) - }) - - it('handles token decoding errors', async () => { - const { getSessionToken } = await import('../../token') - vi.mocked(getSessionToken).mockReturnValue('invalid-token') - - const { jwtDecode } = await import('jwt-decode') - vi.mocked(jwtDecode).mockImplementation(() => { - throw new Error('Invalid token') - }) - - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - render( - - - - ) - - expect(screen.getByTestId('principal')).toHaveTextContent( - 'unauthenticated' - ) - expect(consoleSpy).toHaveBeenCalledWith( - 'Error decoding token', - expect.any(Error) - ) - - consoleSpy.mockRestore() - }) - it('uses provided requireAuth prop', async () => { const { getSessionToken } = await import('../../token') vi.mocked(getSessionToken).mockReturnValue('mock-token') diff --git a/packages/react-components/src/index.tsx b/packages/react-components/src/index.tsx index bfb3129..41ebae7 100644 --- a/packages/react-components/src/index.tsx +++ b/packages/react-components/src/index.tsx @@ -4,7 +4,12 @@ export * from './contexts' export * from './runme/client' export { TypingCell, ChatSequence } from './components/Chat/Chat' export * from './components/Actions/icons' -export { generateSessionName } from './storage' +export { + generateSessionName, + type ISessionStorage, + type SessionNotebook, + type SessionRecord, +} from './storage' // Export layout export { default as Layout } from './layout' diff --git a/packages/react-components/src/layout.tsx b/packages/react-components/src/layout.tsx index 8bbcc34..af5ea92 100644 --- a/packages/react-components/src/layout.tsx +++ b/packages/react-components/src/layout.tsx @@ -7,6 +7,7 @@ import { Box, Flex, Text } from '@radix-ui/themes' import { AppBranding } from './App' import TopNavigation from './components/TopNavigation' import { useSettings } from './contexts/SettingsContext' +import { getSessionToken } from './token' function Layout({ branding, @@ -67,7 +68,9 @@ function Layout({ - + } + /> diff --git a/packages/react-components/src/storage.ts b/packages/react-components/src/storage.ts index 2e47b41..bf07878 100644 --- a/packages/react-components/src/storage.ts +++ b/packages/react-components/src/storage.ts @@ -24,7 +24,37 @@ export interface SessionRecord { export type SessionNotebook = SessionRecord -export class SessionStorage extends Dexie { +/** + * Storage interface for session persistence. + * Implementations can use IndexedDB (Dexie), Supabase Postgres, or other backends. + */ +export interface ISessionStorage { + /** The owner/user identifier, used for scoping queries and RLS */ + readonly principal: string + + /** Save or update a notebook for a session */ + saveNotebook(id: string, notebook: Notebook): Promise | void + + /** Load a single session by id */ + loadSession(id: string): Promise + + /** Load multiple sessions by their ids */ + loadSessions(ids: string[]): Promise + + /** List all sessions for the principal (sorted by updated desc) */ + listSessions(): Promise + + /** List session ids that are still active (created in last 24 hours and valid) */ + listActiveSessions(): Promise + + /** Delete a session by id */ + deleteSession(id: string): Promise + + /** Create a new session and return its id */ + createSession(): Promise +} + +export class DexieSessionStorage extends Dexie implements ISessionStorage { sessions!: Table readonly principal: string readonly client: RunnerClient @@ -159,3 +189,6 @@ export class SessionStorage extends Dexie { export function generateSessionName(): string { return new Date().toISOString() } + +/** @deprecated Use DexieSessionStorage directly. Alias for backwards compatibility. */ +export { DexieSessionStorage as SessionStorage }