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:
co-authored by
Claude Fable 5
parent
6fb42bb3ef
commit
c254d487ba
+12
-5
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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
@@ -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 })
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user