Recover from a dead session, and report a node problem once

An expired or orphaned session left the app on a half-rendered page instead of
the login screen: a properly signed token naming a user who no longer exists
answered 404, which the client does not treat as an authentication failure. All
failures in get_current_user are 401 now, and the client stops retrying them,
so a dead session goes straight back to the login screen.

- Clicking an edge names the two nodes it runs between, not just the message.
- A node with a problem shows one badge carrying the whole explanation, rather
  than a corner badge and a status dot saying the same thing twice. The dot is
  back to what it is good at: whether the node ran.
- The sidebar's collapse control sits in the sidebar, where a phone still finds
  one in the chrome because there is no sidebar on screen to hold it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
Melvin Strobl
2026-08-15 19:19:57 +02:00
co-authored by Claude Fable 5
parent 6fb42bb3ef
commit c254d487ba
9 changed files with 128 additions and 24 deletions
+12 -5
View File
@@ -47,6 +47,12 @@ def user_from_token(session: Session, token: str) -> User | None:
def get_current_user(session: SessionDep, token: TokenDep) -> User: def get_current_user(session: SessionDep, token: TokenDep) -> User:
"""Resolve the bearer token to its user.
Every failure here is an authentication failure, so all of them answer 401:
a token naming a user who no longer exists is a session to log in again,
not a missing resource to report.
"""
try: try:
payload = jwt.decode( payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
@@ -54,14 +60,15 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
token_data = TokenPayload(**payload) token_data = TokenPayload(**payload)
except (InvalidTokenError, ValidationError): except (InvalidTokenError, ValidationError):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
) )
user = session.get(User, token_data.sub) user = session.get(User, token_data.sub)
if not user: if user is None or not user.is_active:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(
if not user.is_active: status_code=status.HTTP_401_UNAUTHORIZED,
raise HTTPException(status_code=400, detail="Inactive user") detail="This session is no longer valid — please log in again",
)
return user return user
+45
View File
@@ -0,0 +1,45 @@
"""A session the server will not accept must say so as an auth failure.
The client clears the stored token and returns to the login screen on 401. Any
other status leaves the browser holding a token that can never work, stuck on a
page that will not load.
"""
import uuid
from datetime import timedelta
from fastapi.testclient import TestClient
from app.core import security
from app.core.config import settings
PROTECTED = [
f"{settings.API_V1_STR}/users/me",
f"{settings.API_V1_STR}/flows/",
]
def token_for(subject: str) -> str:
return security.create_access_token(subject, timedelta(days=1))
def test_no_token_is_unauthorised(client: TestClient) -> None:
for path in PROTECTED:
assert client.get(path).status_code == 401
def test_a_token_we_did_not_sign_is_unauthorised(client: TestClient) -> None:
headers = {"Authorization": "Bearer not.a.real.token"}
for path in PROTECTED:
assert client.get(path, headers=headers).status_code == 401
def test_a_token_for_a_user_who_is_gone_is_unauthorised(
client: TestClient,
) -> None:
# Properly signed, but the user it names no longer exists — which is what a
# browser holds after the database is reset.
headers = {"Authorization": f"Bearer {token_for(str(uuid.uuid4()))}"}
for path in PROTECTED:
response = client.get(path, headers=headers)
assert response.status_code == 401, path
+16 -3
View File
@@ -1,3 +1,5 @@
import { ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover" import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -15,13 +17,15 @@ function relativeTime(ts: number | null | undefined): string {
export type InspectedEdge = { export type InspectedEdge = {
message: string message: string
/** Node titles, so the popover names the two ends in the user's own words. */
from: string
to: string
x: number x: number
y: number y: number
} }
/** /**
* What last travelled along an edge. The value is whatever the producing node * What last travelled along an edge, and between which two nodes.
* published, shown as it was serialised.
*/ */
export function EdgeInspector({ export function EdgeInspector({
edge, edge,
@@ -48,7 +52,16 @@ export function EdgeInspector({
className="w-72 p-3" className="w-72 p-3"
data-testid="edge-inspector" data-testid="edge-inspector"
> >
<p className="font-mono text-xs text-muted-foreground"> <p className="flex items-center gap-1.5 text-sm">
<span className="min-w-0 flex-1 truncate font-medium">
{edge.from}
</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-right font-medium">
{edge.to}
</span>
</p>
<p className="mt-1 font-mono text-xs text-muted-foreground">
{displayName(flow, edge.message)} {displayName(flow, edge.message)}
</p> </p>
+6 -1
View File
@@ -166,7 +166,6 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
flow: flowName, flow: flowName,
typeLabel: typeLabel:
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "", typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
issues: nodeIssues.length,
issueText: nodeIssues.join("\n"), issueText: nodeIssues.join("\n"),
} satisfies FlowNodeData, } satisfies FlowNodeData,
} }
@@ -413,8 +412,14 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
setInspected(null) setInspected(null)
}} }}
onEdgeClick={(event, edge) => { onEdgeClick={(event, edge) => {
const label = (id: string) => {
const node = definitions.find((entry) => entry.id === id)
return node?.title || node?.id || id
}
setInspected({ setInspected({
message: (edge.data as { message: string }).message, message: (edge.data as { message: string }).message,
from: label(edge.source),
to: label(edge.target),
x: event.clientX, x: event.clientX,
y: event.clientY, y: event.clientY,
}) })
+20 -11
View File
@@ -29,17 +29,17 @@ const NODE_ICONS = {
mlp: Braces, mlp: Braces,
} as const } as const
// Only the states worth a quiet marker. Anything wrong goes to the badge
// instead, so a problem is never reported twice on the same node.
const STATUS_STYLES = { const STATUS_STYLES = {
running: { dot: "bg-primary animate-pulse", label: "Running" }, running: { dot: "bg-primary animate-pulse", label: "Running" },
success: { dot: "bg-status-success", label: "Last run succeeded" }, success: { dot: "bg-status-success", label: "Last run succeeded" },
error: { dot: "bg-destructive", label: "Failed" },
} as const } as const
export type FlowNodeData = { export type FlowNodeData = {
definition: NodeDef_Input definition: NodeDef_Input
flow: string flow: string
typeLabel: string typeLabel: string
issues: number
issueText: string issueText: string
[key: string]: unknown [key: string]: unknown
} }
@@ -83,15 +83,18 @@ function PortHandles({
} }
function FlowNodeComponent({ data, selected }: NodeProps) { function FlowNodeComponent({ data, selected }: NodeProps) {
const { definition, flow, typeLabel, issues, issueText } = const { definition, flow, typeLabel, issueText } = data as FlowNodeData
data as FlowNodeData
const live = useNodeStatus(`${flow}.${definition.id}`) const live = useNodeStatus(`${flow}.${definition.id}`)
const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2 const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2
const status = live?.status === "active" ? undefined : live?.status // Whatever is wrong — it failed to load, it failed to run, or the graph
const style = status // around it does not add up — is one badge with one explanation.
? STATUS_STYLES[status as keyof typeof STATUS_STYLES] const problem = [live?.status === "error" ? live.error : null, issueText]
: undefined .filter(Boolean)
.join("\n")
const style = problem
? undefined
: STATUS_STYLES[live?.status as keyof typeof STATUS_STYLES]
return ( return (
<div <div
@@ -132,14 +135,20 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
) : null} ) : null}
</div> </div>
{issues > 0 ? ( {problem ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-destructive text-primary-foreground"> <span
role="img"
aria-label="This node has a problem"
className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-destructive text-primary-foreground"
>
<AlertCircle className="size-3" /> <AlertCircle className="size-3" />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-xs">{issueText}</TooltipContent> <TooltipContent className="max-w-xs whitespace-pre-line">
{problem}
</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
+1 -1
View File
@@ -131,7 +131,7 @@ export function FlowTabs({
transition={transitions.emphasized} transition={transitions.emphasized}
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md" className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md"
> >
<SidebarTrigger className="size-8 shrink-0 text-muted-foreground" /> <SidebarTrigger className="size-8 shrink-0 text-muted-foreground md:hidden" />
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <div className="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{flows.map((flow) => ( {flows.map((flow) => (
+10 -1
View File
@@ -7,6 +7,7 @@ import {
SidebarContent, SidebarContent,
SidebarFooter, SidebarFooter,
SidebarHeader, SidebarHeader,
SidebarTrigger,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
import { type Item, Main } from "./Main" import { type Item, Main } from "./Main"
@@ -33,7 +34,15 @@ export function AppSidebar() {
className="[&>[data-sidebar=sidebar]]:bg-card/80 [&>[data-sidebar=sidebar]]:backdrop-blur-md [&>[data-sidebar=sidebar]]:shadow-e2" className="[&>[data-sidebar=sidebar]]:bg-card/80 [&>[data-sidebar=sidebar]]:backdrop-blur-md [&>[data-sidebar=sidebar]]:shadow-e2"
> >
<SidebarHeader className="px-4 py-6 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center"> <SidebarHeader className="px-4 py-6 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center">
<Logo variant="responsive" /> <div className="flex w-full items-center justify-between gap-2 group-data-[collapsible=icon]:justify-center">
{/* Collapsed, the rail has room for one thing, and that is the way
back out. */}
<span className="group-data-[collapsible=icon]:hidden">
<Logo variant="responsive" />
</span>
{/* On a phone the sidebar is a sheet with its own way in and out. */}
<SidebarTrigger className="hidden shrink-0 text-muted-foreground md:inline-flex" />
</div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<Main items={items} /> <Main items={items} />
+15 -1
View File
@@ -19,12 +19,17 @@ OpenAPI.TOKEN = async () => {
return localStorage.getItem("access_token") || "" return localStorage.getItem("access_token") || ""
} }
/** A session the server will not accept, whatever we do next. */
const isAuthFailure = (error: unknown) =>
error instanceof ApiError && [401, 403].includes(error.status)
const handleApiError = (error: Error) => { const handleApiError = (error: Error) => {
if (error instanceof ApiError && [401, 403].includes(error.status)) { if (isAuthFailure(error)) {
localStorage.removeItem("access_token") localStorage.removeItem("access_token")
window.location.href = "/login" window.location.href = "/login"
} }
} }
const queryClient = new QueryClient({ const queryClient = new QueryClient({
queryCache: new QueryCache({ queryCache: new QueryCache({
onError: handleApiError, onError: handleApiError,
@@ -32,6 +37,15 @@ const queryClient = new QueryClient({
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onError: handleApiError, onError: handleApiError,
}), }),
defaultOptions: {
queries: {
// Retrying an expired session only delays the trip to the login screen.
retry: (count, error) => !isAuthFailure(error) && count < 3,
},
mutations: {
retry: false,
},
},
}) })
const router = createRouter({ routeTree }) const router = createRouter({ routeTree })
+3 -1
View File
@@ -25,7 +25,9 @@ function Layout() {
<SidebarProvider className="bg-card"> <SidebarProvider className="bg-card">
<AppSidebar /> <AppSidebar />
<SidebarInset className="bg-card"> <SidebarInset className="bg-card">
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4"> {/* The sidebar carries its own collapse control; a phone has no
sidebar on screen to carry it. */}
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">
<SidebarTrigger className="-ml-1 text-muted-foreground" /> <SidebarTrigger className="-ml-1 text-muted-foreground" />
</header> </header>
<main className="flex-1 p-6 md:p-8"> <main className="flex-1 p-6 md:p-8">