Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 47 additions & 31 deletions packages/react-components/src/components/TopNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand All @@ -35,36 +33,52 @@ const getGravatarUrl = (size = 24) => {
}
}

const UserAvatar = () => (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<span>
<Avatar.Root className="inline-flex size-[24px] select-none items-center justify-center overflow-hidden rounded-full bg-blackA1 align-middle cursor-pointer">
<Avatar.Image
className="size-full rounded-[inherit] object-cover"
src={getGravatarUrl()}
/>
<Avatar.Fallback
className="leading-1 flex size-full items-center justify-center bg-white text-[15px] font-medium text-violet11"
delayMs={600}
>
<PersonIcon />
</Avatar.Fallback>
</Avatar.Root>
</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={8}>
<DropdownMenu.Item asChild>
<a href="/oidc/logout">Logout</a>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
)
const UserAvatar = ({ url, token }: { url?: string; token?: string }) => {
const src = useMemo(() => {
if (url) {
return url
}
return getGravatarUrl(token)
}, [url, token])

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<span>
<Avatar.Root className="inline-flex size-[24px] select-none items-center justify-center overflow-hidden rounded-full bg-blackA1 align-middle cursor-pointer">
<Avatar.Image
className="size-full rounded-[inherit] object-cover"
src={src}
/>
<Avatar.Fallback
className="leading-1 flex size-full items-center justify-center bg-white text-[15px] font-medium text-violet11"
delayMs={600}
>
<PersonIcon />
</Avatar.Fallback>
</Avatar.Root>
</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content sideOffset={8}>
<DropdownMenu.Item asChild>
<a href="/oidc/logout">Logout</a>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
)
}

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)}
Expand Down Expand Up @@ -160,9 +174,11 @@ const TopNavigation = ({ actions }: { actions?: React.ReactNode[] }) => {
<Text>Settings</Text>
</Flex>
</Button>
<UserAvatar />
{avatar && avatar}
</>
)
}

TopNavigation.UserAvatar = UserAvatar

export default TopNavigation
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ describe('TopNavigation', () => {
})

it('renders user avatar', () => {
render(<TopNavigation />)
render(
<TopNavigation avatar={<TopNavigation.UserAvatar token="mock-token" />} />
)
// The avatar should be present (it's a span with avatar styling)
const avatar = document.querySelector('span[class*="size-[24px]"]')
expect(avatar).toBeInTheDocument()
Expand Down
54 changes: 40 additions & 14 deletions packages/react-components/src/contexts/CellContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string>
/** 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)
Expand All @@ -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:') {
Expand All @@ -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,
Expand Down
18 changes: 0 additions & 18 deletions packages/react-components/src/contexts/SettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -30,7 +29,6 @@ interface Settings {
}

interface SettingsContextType {
principal: string
checkRunnerAuth: () => void
createAuthInterceptors: (redirect: boolean) => Interceptor[]
defaultSettings: Settings
Expand Down Expand Up @@ -72,21 +70,6 @@ export const SettingsProvider = ({
}: SettingsProviderProps) => {
const [runnerError, setRunnerError] = useState<StreamError | null>(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:'
Expand Down Expand Up @@ -237,7 +220,6 @@ export const SettingsProvider = ({
return (
<SettingsContext.Provider
value={{
principal,
checkRunnerAuth,
createAuthInterceptors: actualCreateAuthInterceptors,
defaultSettings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ const TestComponent = () => {
const settings = useSettings()
return (
<div>
<span data-testid="principal">{settings.principal}</span>
<span data-testid="agent-endpoint">
{settings.settings.agentEndpoint}
</span>
Expand Down Expand Up @@ -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(
<SettingsProvider>
<TestComponent />
</SettingsProvider>
)

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(
<SettingsProvider>
<TestComponent />
</SettingsProvider>
)

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')
Expand Down
7 changes: 6 additions & 1 deletion packages/react-components/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 4 additions & 1 deletion packages/react-components/src/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -67,7 +68,9 @@ function Layout({
</Flex>
</Link>
<Flex gap="4">
<TopNavigation />
<TopNavigation
avatar={<TopNavigation.UserAvatar token={getSessionToken()} />}
/>
</Flex>
</Flex>
</Box>
Expand Down
35 changes: 34 additions & 1 deletion packages/react-components/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,37 @@ export interface SessionRecord<T> {

export type SessionNotebook = SessionRecord<Notebook>

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> | void

/** Load a single session by id */
loadSession(id: string): Promise<SessionNotebook | undefined>

/** Load multiple sessions by their ids */
loadSessions(ids: string[]): Promise<SessionNotebook[]>

/** List all sessions for the principal (sorted by updated desc) */
listSessions(): Promise<SessionNotebook[]>

/** List session ids that are still active (created in last 24 hours and valid) */
listActiveSessions(): Promise<string[]>

/** Delete a session by id */
deleteSession(id: string): Promise<void>

/** Create a new session and return its id */
createSession(): Promise<string | undefined>
}

export class DexieSessionStorage extends Dexie implements ISessionStorage {
sessions!: Table<SessionNotebook, string>
readonly principal: string
readonly client: RunnerClient
Expand Down Expand Up @@ -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 }