Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/mount-gates-unresolved-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"every-plugin": patch
"everything-dev": patch
---

Mount gates no longer send signed-in visitors to `/login` on a client-rendered full page load. When the router context has not resolved the session yet, the `authenticated` and `admin` gates await `resolveSession` (supplied by the client router from the session query) before deciding. The admin gate now keeps the `?redirect=` return path, so a signed-out visit to an admin page comes back to it after sign-in instead of landing on `/dashboard`.
48 changes: 32 additions & 16 deletions packages/every-plugin/src/ui/manifest/construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,21 @@ export interface GateUser {
}

export interface HostContext {
user?: GateUser;
session?: { user?: GateUser };
user?: GateUser | null;
/** `null` is an authoritative sign-out; `undefined` means not resolved yet. */
session?: { user?: GateUser } | null;
/**
* Loads the session when the context has not resolved it — a
* client-rendered full page load, where the host hands the browser no
* session. Gates await it instead of treating "unknown" as "signed out".
*/
resolveSession?: () => Promise<{ user?: GateUser } | null | undefined>;
}

type GateFn = (args: {
context: HostContext;
location: { pathname: string; searchStr: string };
}) => void;
}) => void | Promise<void>;

/**
* Gate redirects are absolute: gates attach to root-level mount routes, and
Expand All @@ -66,25 +73,34 @@ const HOME_TO: string = "/";
* mount without an entry (or `none`) renders ungated.
*/
const GATES: Partial<Record<MountDef["gate"], GateFn>> = {
session: ({ context, location }) => {
const user = userOf(context);
if (!user) {
throw redirect({
to: LOGIN_TO,
search: { redirect: location.pathname + location.searchStr } as never,
});
}
session: async ({ context, location }) => {
const user = await gateUserOf(context);
if (!user) throw loginRedirect(location);
},
admin: ({ context }) => {
const user = userOf(context);
if (!user) throw redirect({ to: LOGIN_TO });
admin: async ({ context, location }) => {
const user = await gateUserOf(context);
if (!user) throw loginRedirect(location);
if (user.role !== "admin") throw redirect({ to: HOME_TO });
},
};

const loginRedirect = (location: { pathname: string; searchStr: string }) =>
redirect({
to: LOGIN_TO,
search: { redirect: location.pathname + location.searchStr } as never,
});

const userOf = (context: HostContext | undefined): GateUser | undefined =>
context?.user ?? context?.session?.user;

const isResolved = (context: HostContext | undefined): boolean =>
context?.user !== undefined || context?.session !== undefined;

async function gateUserOf(context: HostContext | undefined): Promise<GateUser | undefined> {
if (isResolved(context) || !context?.resolveSession) return userOf(context);
return (await context.resolveSession())?.user ?? undefined;
}

export interface ConstructPluginRef {
/** composition identity — plugin key; participates in the digest */
key: string;
Expand Down Expand Up @@ -148,8 +164,8 @@ function layoutBeforeLoad(
declared: RouteOptionsBundle["beforeLoad"],
): RouteOptionsBundle["beforeLoad"] {
if (!gate) return declared;
return (args) => {
gate(args);
return async (args) => {
await gate(args);
return declared?.(args);
};
}
Expand Down
19 changes: 12 additions & 7 deletions packages/everything-dev/src/ui/router-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
createBrowserHistory,
createRouter as createTanStackRouter,
} from "@tanstack/react-router";
import { createAuthClient } from "./auth";
import { type AuthClient, createAuthClient, sessionQueryOptions } from "./auth";
import { defaultNotFoundComponent, defaultPendingComponent } from "./router-defaults";
import { RouterError } from "./router-error";
import type { CreateRouterOptions, RouterContextWithApi } from "./types";
Expand Down Expand Up @@ -42,6 +42,13 @@ export function createRouter<
);
}

const authClient =
opts.context.authClient ??
createAuthClient({
runtimeConfig: opts.context.runtimeConfig,
cspNonce: opts.context.cspNonce,
});

const router = createTanStackRouter({
routeTree: routeTree as TRouteTree,
history,
Expand All @@ -51,12 +58,10 @@ export function createRouter<
runtimeConfig: opts.context.runtimeConfig,
cspNonce: opts.context.cspNonce,
apiClient: opts.context.apiClient,
authClient:
opts.context.authClient ??
createAuthClient({
runtimeConfig: opts.context.runtimeConfig,
cspNonce: opts.context.cspNonce,
}),
authClient,
// A client-rendered full load carries no host session; mount gates
// await this read instead of redirecting a signed-in visitor.
resolveSession: () => queryClient.query(sessionQueryOptions(authClient as AuthClient)),
session: opts.context.session,
locale: opts.context.locale,
pluginNav: opts.context.pluginNav,
Expand Down
146 changes: 100 additions & 46 deletions packages/everything-dev/tests/ui/manifest-construct.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ const route = (id: string, path: string, extra: Partial<RouteRecord> = {}): Rout
...extra,
});

const gateOf = (tree: { rootRoute: unknown }) =>
(tree.rootRoute as { children: Array<{ options: Record<string, unknown> }> }).children[0]!.options
.beforeLoad as (args: unknown) => Promise<unknown>;

async function redirectOf(
beforeLoad: (args: unknown) => Promise<unknown>,
args: unknown,
): Promise<{ to?: string; search?: unknown } | undefined> {
try {
await beforeLoad(args);
return undefined;
} catch (error) {
expect(isRedirect(error)).toBe(true);
return (error as { options: { to?: string; search?: unknown } }).options;
}
}

describe("constructTree", () => {
afterEach(() => vi.restoreAllMocks());

Expand All @@ -69,8 +86,9 @@ describe("constructTree", () => {
.children[0]!;
const beforeLoad = mount.options.beforeLoad as (args: unknown) => Promise<unknown>;

const gateThrow = () => beforeLoad({ context: {}, location: { pathname: "/", searchStr: "" } });
expect(gateThrow).toThrow();
await expect(
beforeLoad({ context: {}, location: { pathname: "/", searchStr: "" } }),
).rejects.toSatisfy(isRedirect);

const returned = await beforeLoad({
context: { user: { id: "u1" } },
Expand Down Expand Up @@ -343,23 +361,21 @@ describe("constructTree", () => {
const mount = (
tree.rootRoute as unknown as { children: Array<{ options: Record<string, unknown> }> }
).children[0]!;
const beforeLoad = mount.options.beforeLoad as (args: unknown) => void;

let thrown: unknown;
try {
beforeLoad({ context: {}, location: { pathname: "/settings", searchStr: "" } });
} catch (error) {
thrown = error;
}
expect(isRedirect(thrown)).toBe(true);
expect((thrown as { options: { to: string } }).options.to).toBe("/login");
expect((thrown as { options: { search: unknown } }).options.search).toEqual({
redirect: "/settings",
});
const beforeLoad = mount.options.beforeLoad as (args: unknown) => Promise<unknown>;

expect(
await redirectOf(beforeLoad, {
context: {},
location: { pathname: "/settings", searchStr: "" },
}),
).toEqual(expect.objectContaining({ to: "/login", search: { redirect: "/settings" } }));

expect(() =>
beforeLoad({ context: { user: { id: "u1" } }, location: { pathname: "/settings" } }),
).not.toThrow();
expect(
await redirectOf(beforeLoad, {
context: { user: { id: "u1" } },
location: { pathname: "/settings", searchStr: "" },
}),
).toBeUndefined();
});

it("admin gate sends anonymous users to login and non-admins home", async () => {
Expand All @@ -369,37 +385,74 @@ describe("constructTree", () => {
};
const tree = await construct([core]);

const beforeLoad = (
tree.rootRoute as unknown as { children: Array<{ options: Record<string, unknown> }> }
).children[0]!.options.beforeLoad as (args: unknown) => void;

const redirectOf = (args: unknown): { to: string } | undefined => {
try {
beforeLoad(args);
return undefined;
} catch (error) {
expect(isRedirect(error)).toBe(true);
return (error as { options: { to: string } }).options;
}
};
const beforeLoad = gateOf(tree);

expect(redirectOf({ context: {}, location: { pathname: "/panel", searchStr: "" } })?.to).toBe(
"/login",
);
expect(
redirectOf({
context: { user: { id: "u1", role: "member" } },
location: { pathname: "/panel", searchStr: "" },
})?.to,
await redirectOf(beforeLoad, {
context: {},
location: { pathname: "/panel", searchStr: "?tab=1" },
}),
).toEqual(expect.objectContaining({ to: "/login", search: { redirect: "/panel?tab=1" } }));
expect(
(
await redirectOf(beforeLoad, {
context: { user: { id: "u1", role: "member" } },
location: { pathname: "/panel", searchStr: "" },
})
)?.to,
).toBe("/");
expect(
redirectOf({
await redirectOf(beforeLoad, {
context: { user: { id: "u1", role: "admin" } },
location: { pathname: "/panel" },
location: { pathname: "/panel", searchStr: "" },
}),
).toBeUndefined();
});

it("gates wait for an unresolved session instead of redirecting a signed-in full page load", async () => {
const core: TestPlugin = {
key: "ui",
routes: [mountLayout("admin"), route("_admin/panel", "/panel", { parentId: "_admin" })],
};
const beforeLoad = gateOf(await construct([core]));
const location = { pathname: "/panel", searchStr: "" };

const resolveSession = vi.fn(async () => ({ user: { id: "u1", role: "admin" } }));
expect(await redirectOf(beforeLoad, { context: { resolveSession }, location })).toBeUndefined();
expect(resolveSession).toHaveBeenCalledOnce();

expect(
await redirectOf(beforeLoad, {
context: { resolveSession: async () => null },
location,
}),
).toEqual(expect.objectContaining({ to: "/login", search: { redirect: "/panel" } }));
});

it("gates trust a resolved session, including an authoritative sign-out, without reading again", async () => {
const core: TestPlugin = {
key: "ui",
routes: [
mountLayout("authenticated"),
route("_authenticated/x", "/x", { parentId: "_authenticated" }),
],
};
const beforeLoad = gateOf(await construct([core]));
const location = { pathname: "/x", searchStr: "" };
const resolveSession = vi.fn(async () => ({ user: { id: "u1" } }));

expect(
await redirectOf(beforeLoad, {
context: { session: { user: { id: "u1" } }, resolveSession },
location,
}),
).toBeUndefined();
expect(
(await redirectOf(beforeLoad, { context: { session: null, resolveSession }, location }))?.to,
).toBe("/login");
expect(resolveSession).not.toHaveBeenCalled();
});

it("reads the gate user from either context.user or context.session.user", async () => {
const core: TestPlugin = {
key: "ui",
Expand All @@ -409,13 +462,14 @@ describe("constructTree", () => {
],
};
const tree = await construct([core]);
const beforeLoad = (
tree.rootRoute as unknown as { children: Array<{ options: Record<string, unknown> }> }
).children[0]!.options.beforeLoad as (args: unknown) => void;
const beforeLoad = gateOf(tree);

expect(() =>
beforeLoad({ context: { session: { user: { id: "u1" } } }, location: { pathname: "/x" } }),
).not.toThrow();
expect(
await redirectOf(beforeLoad, {
context: { session: { user: { id: "u1" } } },
location: { pathname: "/x", searchStr: "" },
}),
).toBeUndefined();
});

it("shadows a plugin's duplicate mount declaration (core wins)", async () => {
Expand Down
27 changes: 27 additions & 0 deletions packages/everything-dev/tests/ui/router-client-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { QueryClient } from "@tanstack/react-query";
import { createMemoryHistory, createRootRoute } from "@tanstack/react-router";
import { describe, expect, it, vi } from "vitest";
import { sessionQueryKey } from "../../src/ui/auth";
import { createRouter } from "../../src/ui/router-client";

describe("client router session resolution", () => {
it("gives mount gates a resolveSession that reads through the session query", async () => {
const queryClient = new QueryClient();
const session = { user: { id: "u1", role: "admin" } };
const getSession = vi.fn(async () => ({ data: session, error: null }));

const { router } = createRouter({
routeTree: createRootRoute(),
history: createMemoryHistory({ initialEntries: ["/admin"] }),
context: { queryClient, authClient: { getSession } },
});
const { resolveSession } = router.options.context as {
resolveSession: () => Promise<unknown>;
};

await expect(resolveSession()).resolves.toEqual(session);
await expect(resolveSession()).resolves.toEqual(session);
expect(getSession).toHaveBeenCalledOnce();
expect(queryClient.getQueryData(sessionQueryKey)).toEqual(session);
});
});
Loading