feat: add public SharedSessionPage with tree preview
Add the public-facing shared session page at /share/:shareToken that renders shared sessions without authentication. Includes error handling for 401 (redirect to login), 403 (access denied), 404 (not found), and 410 (expired). The page features a minimal header, session metadata, SessionTimeline component, and a new SharedSessionTreePreview component that renders the decision tree structure with the path taken highlighted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
88
frontend/src/components/session/SharedSessionTreePreview.tsx
Normal file
88
frontend/src/components/session/SharedSessionTreePreview.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SharedSessionTreePreviewProps {
|
||||||
|
treeStructure: Record<string, unknown>
|
||||||
|
pathTaken: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypeColors: Record<string, string> = {
|
||||||
|
root: 'bg-white',
|
||||||
|
decision: 'bg-blue-400',
|
||||||
|
action: 'bg-yellow-400',
|
||||||
|
solution: 'bg-emerald-400',
|
||||||
|
information: 'bg-white/50',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeTitle(node: Record<string, unknown>): string {
|
||||||
|
return (
|
||||||
|
(node.question as string) ||
|
||||||
|
(node.title as string) ||
|
||||||
|
(node.node_type as string) ||
|
||||||
|
'Untitled'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNode({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
pathTaken,
|
||||||
|
}: {
|
||||||
|
node: Record<string, unknown>
|
||||||
|
depth: number
|
||||||
|
pathTaken: string[]
|
||||||
|
}) {
|
||||||
|
const nodeId = (node.id as string) || ''
|
||||||
|
const nodeType = (node.node_type as string) || 'decision'
|
||||||
|
const isInPath = pathTaken.includes(nodeId)
|
||||||
|
const children = (node.children as Record<string, unknown>[]) || []
|
||||||
|
const colorClass = nodeTypeColors[nodeType] || 'bg-white/50'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 text-sm',
|
||||||
|
isInPath
|
||||||
|
? 'rounded-md border-l-2 border-white/40 bg-white/10 font-medium text-white'
|
||||||
|
: 'text-white/30'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn('h-2 w-2 shrink-0 rounded-full', colorClass)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{getNodeTitle(node)}</span>
|
||||||
|
</div>
|
||||||
|
{children.map((child, index) => (
|
||||||
|
<TreeNode
|
||||||
|
key={(child.id as string) || index}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
pathTaken={pathTaken}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharedSessionTreePreview({
|
||||||
|
treeStructure,
|
||||||
|
pathTaken,
|
||||||
|
}: SharedSessionTreePreviewProps) {
|
||||||
|
if (!treeStructure) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card rounded-2xl">
|
||||||
|
<div className="sticky top-0 z-10 rounded-t-2xl border-b border-white/[0.06] bg-black/80 px-6 py-4 backdrop-blur">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Tree Structure</h3>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[600px] overflow-y-auto py-2">
|
||||||
|
<TreeNode node={treeStructure} depth={0} pathTaken={pathTaken} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SharedSessionTreePreview
|
||||||
263
frontend/src/pages/SharedSessionPage.tsx
Normal file
263
frontend/src/pages/SharedSessionPage.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { Globe, Users, ShieldAlert, FileX, Clock, Loader2 } from 'lucide-react'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { sessionsApi } from '@/api/sessions'
|
||||||
|
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||||
|
import { SessionTimeline } from '@/components/session/SessionTimeline'
|
||||||
|
import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview'
|
||||||
|
import type { SharedSessionView } from '@/types'
|
||||||
|
|
||||||
|
function formatDate(dateString: string) {
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startedAt: string, completedAt: string): string {
|
||||||
|
const start = new Date(startedAt).getTime()
|
||||||
|
const end = new Date(completedAt).getTime()
|
||||||
|
const totalSeconds = Math.floor((end - start) / 1000)
|
||||||
|
if (totalSeconds < 0) return '0s'
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds}s`
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
if (hours > 0) {
|
||||||
|
return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
|
||||||
|
}
|
||||||
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorState = {
|
||||||
|
type: 'access_denied' | 'not_found' | 'expired' | 'generic'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ error }: { error: ErrorState }) {
|
||||||
|
const iconMap = {
|
||||||
|
access_denied: ShieldAlert,
|
||||||
|
not_found: FileX,
|
||||||
|
expired: Clock,
|
||||||
|
generic: FileX,
|
||||||
|
}
|
||||||
|
const titleMap = {
|
||||||
|
access_denied: 'Access Denied',
|
||||||
|
not_found: 'Not Found',
|
||||||
|
expired: 'Link Expired',
|
||||||
|
generic: 'Error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = iconMap[error.type]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||||
|
<div className="glass-card w-full max-w-md rounded-2xl p-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white/5">
|
||||||
|
<Icon className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-2 text-xl font-semibold text-white">{titleMap[error.type]}</h1>
|
||||||
|
<p className="mb-6 text-sm text-white/50">{error.message}</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-block rounded-lg bg-white px-6 py-2.5 text-sm font-medium text-black hover:bg-white/90"
|
||||||
|
>
|
||||||
|
Go to ResolutionFlow
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharedSessionPage() {
|
||||||
|
const { shareToken } = useParams<{ shareToken: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [data, setData] = useState<SharedSessionView | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<ErrorState | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shareToken) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function fetchSharedSession() {
|
||||||
|
try {
|
||||||
|
const result = await sessionsApi.getSharedSession(shareToken!)
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(result)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (isAxiosError(err)) {
|
||||||
|
const status = err.response?.status
|
||||||
|
if (status === 401) {
|
||||||
|
navigate('/login', {
|
||||||
|
state: { from: `/share/${shareToken}` },
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
setError({
|
||||||
|
type: 'access_denied',
|
||||||
|
message:
|
||||||
|
'This session is private to the account. You need to be a member of the account to view it.',
|
||||||
|
})
|
||||||
|
} else if (status === 404) {
|
||||||
|
setError({
|
||||||
|
type: 'not_found',
|
||||||
|
message: 'This share link was not found or has been revoked.',
|
||||||
|
})
|
||||||
|
} else if (status === 410) {
|
||||||
|
setError({
|
||||||
|
type: 'expired',
|
||||||
|
message: 'This share link has expired.',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setError({
|
||||||
|
type: 'generic',
|
||||||
|
message: 'Failed to load shared session. Please try again.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError({
|
||||||
|
type: 'generic',
|
||||||
|
message: 'Failed to load shared session. Please try again.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSharedSession()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [shareToken, navigate])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-black">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
|
||||||
|
<p className="text-sm text-white/40">Loading shared session...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorCard error={error} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black">
|
||||||
|
{/* Minimal header */}
|
||||||
|
<header className="border-b border-white/[0.06] px-6 py-4">
|
||||||
|
<div className="mx-auto flex max-w-7xl items-center justify-between">
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<BrandLogo size="sm" />
|
||||||
|
<span className="text-lg font-semibold text-white">ResolutionFlow</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="container mx-auto max-w-7xl px-4 py-8">
|
||||||
|
{/* Metadata section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
{data.share_name && (
|
||||||
|
<h1 className="mb-2 text-2xl font-bold text-white">{data.share_name}</h1>
|
||||||
|
)}
|
||||||
|
<p className="text-lg text-white/70">
|
||||||
|
<span className="text-white/40">Tree:</span> {data.tree_name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(data.ticket_number || data.client_name) && (
|
||||||
|
<p className="mt-1 text-sm text-white/50">
|
||||||
|
{data.ticket_number && (
|
||||||
|
<span>Ticket: #{data.ticket_number}</span>
|
||||||
|
)}
|
||||||
|
{data.ticket_number && data.client_name && (
|
||||||
|
<span className="mx-1.5">·</span>
|
||||||
|
)}
|
||||||
|
{data.client_name && (
|
||||||
|
<span>Client: {data.client_name}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-white/40">
|
||||||
|
<span>Started: {formatDate(data.started_at)}</span>
|
||||||
|
{data.completed_at && (
|
||||||
|
<>
|
||||||
|
<span>Completed: {formatDate(data.completed_at)}</span>
|
||||||
|
<span>Duration: {formatDuration(data.started_at, data.completed_at)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{data.visibility === 'public' ? (
|
||||||
|
<>
|
||||||
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
Public
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Account
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Decision Timeline (2 cols) */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<SessionTimeline
|
||||||
|
decisions={data.decisions}
|
||||||
|
treeType={data.tree_structure?.tree_type as string}
|
||||||
|
startedAt={data.started_at}
|
||||||
|
completedAt={data.completed_at}
|
||||||
|
showCopyButtons={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree Preview (1 col) */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<SharedSessionTreePreview
|
||||||
|
treeStructure={data.tree_structure}
|
||||||
|
pathTaken={data.path_taken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-8 text-center text-sm text-white/30">
|
||||||
|
Powered by{' '}
|
||||||
|
<Link to="/" className="underline hover:text-white/50">
|
||||||
|
ResolutionFlow
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SharedSessionPage
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
RegisterPage,
|
RegisterPage,
|
||||||
} from '@/pages'
|
} from '@/pages'
|
||||||
|
|
||||||
|
// Public pages
|
||||||
|
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
|
||||||
|
|
||||||
// Standalone auth pages
|
// Standalone auth pages
|
||||||
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
|
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
|
||||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
|
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
|
||||||
@@ -69,6 +72,15 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
errorElement: <RouteError />,
|
errorElement: <RouteError />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/share/:shareToken',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<SharedSessionPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
errorElement: <RouteError />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/change-password',
|
path: '/change-password',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
Reference in New Issue
Block a user