Skip to content

Commit 4460596

Browse files
authored
Real 404 pages and per-route document titles (#1272)
* Retry setup-status checks before showing auth * Show real not found pages and route titles
1 parent 9a1856e commit 4460596

16 files changed

Lines changed: 103 additions & 11 deletions

File tree

apps/host-selfhost/web/routes/__root.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { fetchNeedsSetup } from "../setup-status";
3333
// ---------------------------------------------------------------------------
3434

3535
export const Route = createRootRoute({
36+
notFoundComponent: NotFoundPage,
3637
component: RootComponent,
3738
});
3839

@@ -50,6 +51,26 @@ const signOut = async () => {
5051
window.location.href = "/";
5152
};
5253

54+
function NotFoundPage() {
55+
return (
56+
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-10">
57+
<section className="w-full max-w-md text-center">
58+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">404</p>
59+
<h1 className="mt-2 text-xl font-semibold text-foreground">Page not found</h1>
60+
<p className="mt-2 text-sm leading-6 text-muted-foreground">
61+
There&apos;s nothing at this address.
62+
</p>
63+
<a
64+
href="/"
65+
className="mt-6 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
66+
>
67+
Go home
68+
</a>
69+
</section>
70+
</main>
71+
);
72+
}
73+
5374
const Loading = () => (
5475
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
5576
Loading…

apps/host-selfhost/web/routes/app/admin.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CopyButton } from "@executor-js/react/components/copy-button";
1010
import { Input } from "@executor-js/react/components/input";
1111
import { Label } from "@executor-js/react/components/label";
1212
import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
13+
import { useExecutorDocumentTitle } from "@executor-js/react/lib/document-title";
1314
import {
1415
orgMembersAtom,
1516
removeMember,
@@ -29,6 +30,7 @@ const ROLES = ["member", "admin"] as const;
2930
// are the self-host join mechanism. The API gates to owner/admin, so a
3031
// non-admin who opens this just sees load failures.
3132
function AdminPage() {
33+
useExecutorDocumentTitle("Admin");
3234
return (
3335
<div className="min-h-0 flex-1 overflow-y-auto">
3436
<div className="mx-auto flex max-w-3xl flex-col gap-10 px-6 py-10 lg:px-8 lg:py-14">

packages/app/src/routes/__root.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,30 @@ import { plugins as clientPlugins } from "virtual:executor/plugins-client";
77
import { Shell } from "../web/shell";
88

99
export const Route = createRootRoute({
10+
notFoundComponent: NotFoundPage,
1011
component: RootComponent,
1112
});
1213

14+
function NotFoundPage() {
15+
return (
16+
<main className="flex min-h-screen items-center justify-center bg-background px-6 py-10">
17+
<section className="w-full max-w-md text-center">
18+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">404</p>
19+
<h1 className="mt-2 text-xl font-semibold text-foreground">Page not found</h1>
20+
<p className="mt-2 text-sm leading-6 text-muted-foreground">
21+
There&apos;s nothing at this address.
22+
</p>
23+
<a
24+
href="/"
25+
className="mt-6 inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
26+
>
27+
Go home
28+
</a>
29+
</section>
30+
</main>
31+
);
32+
}
33+
1334
function RootComponent() {
1435
return (
1536
<ExecutorProvider>

packages/react/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"./components/*": "./src/components/*.tsx",
1818
"./hooks/*": "./src/hooks/*.ts",
1919
"./lib/auth-placements": "./src/lib/auth-placements.tsx",
20+
"./lib/document-title": "./src/lib/document-title.tsx",
2021
"./lib/integration-add": "./src/lib/integration-add.tsx",
2122
"./lib/*": "./src/lib/*.ts",
2223
"./globals.css": "./src/styles/globals.css",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useEffect } from "react";
2+
3+
const APP_NAME = "Executor";
4+
5+
export function executorDocumentTitle(page: string): string {
6+
return `${page} · ${APP_NAME}`;
7+
}
8+
9+
export function useExecutorDocumentTitle(page: string | null | undefined): void {
10+
useEffect(() => {
11+
if (!page || typeof document === "undefined") return;
12+
document.title = executorDocumentTitle(page);
13+
}, [page]);
14+
}

packages/react/src/multiplayer/org-slug-gate.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
11
import { useEffect, type ReactNode } from "react";
2-
import { useNavigate, useParams } from "@tanstack/react-router";
2+
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
33

44
// ---------------------------------------------------------------------------
55
// Org-slug URL canonicalization for org-scoped hosts (cloud, self-host,
66
// cloudflare). Console routes live under an optional `{-$orgSlug}` segment, so
77
// the same tree serves `/policies` and `/acme/policies`; this gate, mounted
8-
// inside the authenticated shell, pins a BARE URL to the active org's slug:
8+
// inside the authenticated shell, pins the URL to the active org's slug:
99
//
10-
// - bare URL → replace with `/<active-slug>/…` (canonicalize)
11-
// - slug already in URL → render
10+
// - bare URL → replace with `/<active-slug>/…` (canonicalize)
11+
// - active slug already in URL → render
12+
// - any other slug in URL → replace with `/<active-slug>/…` (canonicalize)
1213
//
1314
// The URL slug is the request SCOPE, not just a label: every API call carries
1415
// it (the `x-executor-organization` header), and the server re-checks live
1516
// membership and resolves data for that org — same as the MCP URL-pinned org.
16-
// So a foreign slug never reaches this gate as "active": the server returns no
17-
// organization for an org the caller can't see, and the shell 404s upstream.
18-
// That makes two browser tabs on different orgs fully independent — no shared
19-
// "active org" to steal.
17+
// So a foreign slug never reaches this gate as "active" on a multi-org host:
18+
// the server returns no organization for an org the caller can't see, and the
19+
// shell 404s upstream. That makes two browser tabs on different orgs fully
20+
// independent — no shared "active org" to steal. On a single-org host (e.g.
21+
// self-host) every slug resolves to the same org server-side, so a bogus slug
22+
// (e.g. `/totally-bogus`) would otherwise fuzzy-match a route and render
23+
// under the wrong URL forever; canonicalizing it here fixes the URL instead.
24+
//
25+
// A genuinely unmatched path is neither of the above: it has no `orgSlug`
26+
// param (nothing below root matched) but isn't a bare URL either, so it must
27+
// NOT canonicalize — doing so would silently rewrite a bad URL into a valid
28+
// one instead of letting the not-found page render.
2029
// ---------------------------------------------------------------------------
2130

2231
export interface OrgSlugGateProps {
@@ -31,9 +40,13 @@ export function OrgSlugGate(props: OrgSlugGateProps) {
3140
const urlSlug = params.orgSlug ?? null;
3241
const navigate = useNavigate();
3342

34-
// Only a BARE URL canonicalizes. A slug in the URL is the scope the whole
35-
// request chain already ran with, so it always matches `activeSlug` here.
36-
const needsCanonicalize = urlSlug === null;
43+
// Skip canonicalization whenever the router is currently sitting on a
44+
// not-found match — see the file header for why.
45+
const isNotFound = useRouterState({
46+
select: (state) => state.matches.some((match) => match.globalNotFound),
47+
});
48+
49+
const needsCanonicalize = urlSlug !== activeSlug && !isNotFound;
3750

3851
useEffect(() => {
3952
if (!needsCanonicalize) return;

packages/react/src/pages/api-keys.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from "../components/dialog";
2121
import { Input } from "../components/input";
2222
import { Label } from "../components/label";
23+
import { useExecutorDocumentTitle } from "../lib/document-title";
2324

2425
// ---------------------------------------------------------------------------
2526
// Shared API-keys page. Reads/writes the provider-neutral `/account/api-keys`
@@ -58,6 +59,7 @@ const defaultApiKeyName = (): string =>
5859
}).format(new Date())}`;
5960

6061
export function ApiKeysPage() {
62+
useExecutorDocumentTitle("API keys");
6163
const result = useAtomValue(apiKeysAtom);
6264
const doCreate = useAtomSet(createApiKey, { mode: "promiseExit" });
6365
const doRevoke = useAtomSet(revokeApiKey, { mode: "promiseExit" });

packages/react/src/pages/integration-add.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Suspense } from "react";
22
import { Link, useNavigate } from "@tanstack/react-router";
33
import { useIntegrationPlugins } from "@executor-js/sdk/client";
44
import { trackEvent } from "../api/analytics";
5+
import { useExecutorDocumentTitle } from "../lib/document-title";
56

67
// ---------------------------------------------------------------------------
78
// Page
@@ -13,6 +14,7 @@ export function AddIntegrationPage(props: {
1314
preset?: string;
1415
namespace?: string;
1516
}) {
17+
useExecutorDocumentTitle("Add integration");
1618
const { pluginKey, url, preset, namespace } = props;
1719
const navigate = useNavigate();
1820
const integrationPlugins = useIntegrationPlugins();

packages/react/src/pages/integration-detail.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { usePolicyActions } from "../hooks/use-policy-actions";
3333
import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";
3434
import { Button } from "../components/button";
3535
import { Skeleton } from "../components/skeleton";
36+
import { useExecutorDocumentTitle } from "../lib/document-title";
3637

3738
// v2: the route's `namespace` param is the integration slug. Tools belong to
3839
// the integration's per-owner connections; a tool's policy id is
@@ -97,6 +98,7 @@ export function IntegrationDetailPage(props: { namespace: string }) {
9798
}, [namespace]);
9899

99100
const integrationData = AsyncResult.isSuccess(integration) ? integration.value : null;
101+
useExecutorDocumentTitle(integrationData?.name || namespace);
100102
const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
101103
const currentTab = isBuiltInIntegration ? "tools" : activeTab;
102104
const canRefresh = integrationData?.canRefresh ?? false;

packages/react/src/pages/integrations.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from "../components/integration-favicon";
4141
import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
4242
import { Skeleton } from "../components/skeleton";
43+
import { useExecutorDocumentTitle } from "../lib/document-title";
4344

4445
const KIND_TO_PLUGIN_KEY: Record<string, string> = {
4546
openapi: "openapi",
@@ -64,6 +65,7 @@ const bestDetection = (
6465
// ---------------------------------------------------------------------------
6566

6667
export function IntegrationsPage() {
68+
useExecutorDocumentTitle("Integrations");
6769
const integrations = useAtomValue(integrationsOptimisticAtom);
6870
const [connectOpen, setConnectOpen] = useState(false);
6971

0 commit comments

Comments
 (0)