diff --git a/apps/web/src/components/migration-link.tsx b/apps/web/src/components/migration-link.tsx index 30b483080..de5441573 100644 --- a/apps/web/src/components/migration-link.tsx +++ b/apps/web/src/components/migration-link.tsx @@ -1,10 +1,8 @@ -import type { AnyRouter } from '@tanstack/react-router' - import { Link, useRouter } from '@tanstack/react-router' import { useLocale } from '#/lib/i18n/client' -import { localeRouting } from '#/lib/i18n/shared' import { buildLegacyHref, legacyWebOrigin } from '#/lib/legacy-app' +import { isTanStackOwnedPath } from '#/lib/migration-navigation' /** * Linking to a VitNode page while half of VitNode still runs on Next.js. @@ -29,55 +27,12 @@ import { buildLegacyHref, legacyWebOrigin } from '#/lib/legacy-app' * answering `true` for it, and nothing here changes. Stage 5 is the proof: a * plugin declared `/example`, `lib/plugin-routes.ts` mounted it on the same tree, * and this file was not touched. - */ - -/** - * The API mount is not a page. - * - * `/api/$` is a real route in the generated tree - it is how Hono is mounted - - * so it matches, and without this a search result pointing into `/api` would be - * handed to the router as a client-side navigation to a route that renders - * nothing. Matched by route id rather than by a hardcoded pathname, so it stays - * correct if the mount ever moves. - */ -const isApiRouteId = (routeId: string): boolean => - routeId === '/api' || routeId.startsWith('/api/') - -/** - * Whether this app's route tree can render `href` itself. - * - * Three things have to happen before the router is asked, and each one is a way - * this returned the wrong answer while it was being written: - * - * 1. **Strip the query and hash.** `matchRoutes` takes a *pathname*; - * `/discover?a=1` matches nothing. - * 2. **De-localize.** The route tree has no locale in it - that is the whole of - * Stage 3 - so `/pl/discover` matches nothing until the prefix comes off. - * 3. **Reject the API mount.** See {@link isApiRouteId}. * - * An unmatched path resolves to the root route alone, so "something below the - * root matched" is the test. That also means a root-level catch-all route would - * make every path look owned; there is none today, and - * `src/tests/plugin-routes.test.ts` fails loudly if one appears - it asserts that - * `/blog/post-30` is still somebody else's. + * The rule itself lives in `#/lib/migration-navigation`, because a link is not + * the only thing that has to make it: an auth flow finishing a sign-in navigates + * to wherever the visitor was heading, and has to ask exactly the same question. + * One answer, two callers. */ -export const isTanStackOwnedPath = ( - router: AnyRouter, - href: string, -): boolean => { - // The same rule `rewrite.input` applies, from the same Stage 3 helper - the - // rewrite is `deLocalizeUrl` and nothing else, so this is one rule, not a copy. - const { pathname } = localeRouting.deLocalizeUrl( - new URL(href, 'https://vitnode.invalid'), - ) - - const matched = router - .matchRoutes(pathname, undefined) - .map((match: { routeId: string }) => match.routeId) - .filter((routeId: string) => routeId !== '__root__') - - return matched.length > 0 && !matched.some(isApiRouteId) -} /** * A link to anywhere in VitNode, migrated or not. @@ -95,22 +50,32 @@ export const isTanStackOwnedPath = ( * it with the same Stage 3 rule and points it at the legacy origin. * * Search parameters and hashes survive both branches untouched. + * + * ## Every prop of an anchor, not just three + * + * Widened from `{ children, className, href }` for the shared auth screens, + * which put a link inside a Base UI `render`: that clones the element with the + * children, the class name *and the ref* it needs to stay a button, so a wrapper + * accepting only three props would silently drop two of them. The type is now + * structurally `AuthLinkProps` from `@vitnode/core/views/auth/auth-link`, which + * is what lets this component be handed straight to `SignInContent`, + * `SignInFormContent` and `SSOCallbackContent` as their `LinkComponent`. */ +export type MigrationLinkProps = Omit, 'href'> & { + href: string +} + export const MigrationLink = ({ children, - className, href, -}: { - children: React.ReactNode - className?: string - href: string -}) => { + ...props +}: MigrationLinkProps) => { const router = useRouter() const locale = useLocale() if (isTanStackOwnedPath(router, href)) { return ( - + {children} ) @@ -118,7 +83,7 @@ export const MigrationLink = ({ return ( {children} diff --git a/apps/web/src/lib/auth/actions.ts b/apps/web/src/lib/auth/actions.ts new file mode 100644 index 000000000..408214b56 --- /dev/null +++ b/apps/web/src/lib/auth/actions.ts @@ -0,0 +1,165 @@ +import type { SignInSubmit } from '@vitnode/core/views/auth/sign-in/form/sign-in-form-content' +import type { SSOSelectProvider } from '@vitnode/core/views/auth/sso/buttons/sso-buttons-content' +import type { SSOCallbackResult } from '@vitnode/core/views/auth/sso/callback/sso-callback-result' + +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from '@tanstack/react-router' + +import type { SsoCallbackInput } from '#/lib/auth/contract' + +import { completeSso, signIn, signOut, startSso } from '#/lib/auth/mutations' +import { + invalidateSession, + sessionQueryOptions, + setSessionData, +} from '#/lib/auth/query' +import { + anonymousSession, + signInFormResult, + ssoCallbackResult, + ssoStartFeedback, +} from '#/lib/auth/screens' +import { useMigrationNavigate } from '#/lib/migration-navigation' + +/** + * The four things a visitor can do to their own session, as this app's only + * auth actions. + * + * component -> action -> server function -> Hono -> Set-Cookie + * | + * +-> canonical session cache -> route guards + * + * Every one of them ends the same way: the cached session is brought back in + * step with the cookie the browser now holds, *before* anything navigates. That + * ordering is the whole reason these are hooks and not four inline callbacks - + * a navigation that runs first arrives at a guard reading the previous + * visitor's state, which is either a bounce back to the login page or a flash + * of a page the visitor is no longer entitled to. + * + * There is no second auth store. `#/lib/auth/query` owns the one cache entry + * every guard and component reads, and these write to exactly that entry. + * + * None of this is a security boundary. Hono authorizes every private read from + * the session cookie, in its own handlers, and keeps doing so whatever this + * cache says. + */ + +/** + * Signing in, in the shape `SignInFormContent` submits. + * + * `undefined` on success, which is the shared form's way of saying "the caller + * is navigating" - and it is, on the line above. On failure the form gets the + * legacy vocabulary back and renders the alert or the toast itself. + * + * The session is invalidated rather than written, because the sign-in reply says + * only that it worked - the session body comes from the next read, which the + * destination's guard performs through the one query definition. Doing it before + * navigating is what makes that read see the new cookie. + * + * The navigation goes through `useMigrationNavigate` rather than + * `router.navigate`, because `?returnTo=` names somewhere the visitor was + * heading and most of VitNode has not moved yet. `/discover` is a client-side + * navigation; `/settings/security?tab=devices` is a full-document load into the + * Next.js app that still serves it. The route tree decides which - there is no + * list of migrated auth destinations anywhere. + */ +export const useSignInAction = (destination: () => string): SignInSubmit => { + const queryClient = useQueryClient() + const navigate = useMigrationNavigate() + + return async (values) => { + const result = await signIn({ data: values }) + + if (!result.ok) return signInFormResult(result) + + await invalidateSession(queryClient) + await navigate(destination()) + + return undefined + } +} + +/** + * Starting an SSO sign-in. + * + * A plain function rather than a hook: it reads no cached state and moves no + * router, because success leaves this application entirely. + * + * The provider is another origin, so leaving is a full-document navigation + * rather than a router one - and it has to be, because the round trip comes back + * to a URL the provider was told about, not to a client-side route. + * + * The reply to this call carries the API's short-lived `--state-sso` cookie, + * which `saveApiCookies` writes onto the browser before this returns. Navigating + * away any earlier would lose it and the callback would fail its state check. + */ +export const startSsoAction: SSOSelectProvider = async (providerId) => { + const result = await startSso({ data: { providerId } }) + + if (!result.ok) return ssoStartFeedback(result) + + globalThis.location.assign(result.url) + + return undefined +} + +/** + * Finishing an SSO sign-in - the exchange half of `useSSOCallback`. + * + * Takes the parameters `parseSsoCallback` validated, or `null` when the callback + * URL never carried a usable set. `null` answers `unknown` without calling the + * API at all: a callback with no `code` has nothing to exchange, and sending it + * anyway would be a request whose only possible outcome is an error. + */ +export const useCompleteSsoAction = (params: null | SsoCallbackInput) => { + const queryClient = useQueryClient() + + return async (): Promise => { + if (!params) return { failure: 'unknown' } + + const result = await completeSso({ data: params }) + + if (result.ok) await invalidateSession(queryClient) + + return ssoCallbackResult(result) + } +} + +/** + * Signing out. + * + * Two writes, in this order, and both are needed: + * + * 1. **Write the anonymous session.** The reply carries the cookie deletion but + * not a session body, so without this the cache still holds the previous + * visitor until a refetch returns - and every guard and component reading it + * in between believes them still signed in. + * 2. **Invalidate.** The written value is this app's inference, not the server's + * answer; marking it stale means the next reader confirms it. + * + * `router.invalidate()` then re-runs the matched routes' `beforeLoad`, so a + * visitor sitting on a page behind `_authenticated` is redirected out of it by + * the guard that owns that rule, rather than by anything here. + * + * Exported for the shell migration that will mount the header. Nothing in this + * app renders a sign-out control yet, and adding one would mean migrating the + * header, which is a different stage. + */ +export const useSignOutAction = () => { + const queryClient = useQueryClient() + const router = useRouter() + + return async ({ isAdmin = false }: { isAdmin?: boolean } = {}) => { + const result = await signOut({ data: { isAdmin } }) + + if (!result.ok) return result + + const current = queryClient.getQueryData(sessionQueryOptions().queryKey) + if (current) setSessionData(queryClient, anonymousSession(current)) + + await invalidateSession(queryClient) + await router.invalidate() + + return result + } +} diff --git a/apps/web/src/lib/auth/contract.ts b/apps/web/src/lib/auth/contract.ts new file mode 100644 index 000000000..34eed69a6 --- /dev/null +++ b/apps/web/src/lib/auth/contract.ts @@ -0,0 +1,293 @@ +import { z } from 'zod' + +/** + * What the auth mutations accept, and what they answer with. + * + * Pure and framework-free on purpose: no `createServerFn`, no fetcher, no + * cookies. Everything in here is a schema or a total function from an HTTP + * status to a finite result, which is what makes the interesting half of the + * auth transport testable without a server, a database or a browser - and what + * keeps the server functions in `#/lib/auth/mutations` down to "call the API, + * copy the cookies, map the status". + * + * The results are closed unions rather than the API's own JSON. A component + * gets `{ ok: false, reason: 'access_denied' }`, never a body it has to guess + * the shape of and never an internal message - a 500 from the API carries the + * failing URL and the exception text, and none of that belongs in a browser. + */ + +/** + * A provider id, as it may appear in a URL path. + * + * The API's own schema is `z.string()`, and the fetcher interpolates the value + * into the request path *without encoding it* (see `buildApiUrl`), so an + * unchecked id is a path-traversal primitive: `../../..` would resolve to a + * different API endpoint entirely. A conservative slug - what every shipped + * adapter uses (`google`, `discord`, `facebook`) - removes the question rather + * than answering it, since none of `/`, `.` or `%` survives it. + */ +export const providerIdSchema = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/) + +/** + * Email and password, plus the session flavour to mint. + * + * The smallest equivalent of `zodSignInSchema` on the API's sign-in route, + * written out rather than imported: that module pulls in `SessionModel`, + * `UserModel` and the Hono runtime with them, and this schema is reachable from + * the browser bundle. `.toLowerCase()` mirrors what the API does to the address + * before it looks a user up, so the value sent matches the value stored. + * + * `isAdmin` selects the AdminCP session rather than the public one, exactly as + * the legacy server action passed it through. + */ +export const signInInputSchema = z.object({ + email: z.email().toLowerCase(), + isAdmin: z.boolean().optional(), + password: z.string().min(1).max(1024), +}) + +export type SignInInput = z.infer + +/** Which session to end. Mirrors the API's sign-out body. */ +export const signOutInputSchema = z.object({ + isAdmin: z.boolean().optional(), +}) + +export type SignOutInput = z.infer + +/** Which provider to start a sign-in with. */ +export const ssoStartInputSchema = z.object({ + providerId: providerIdSchema, +}) + +export type SsoStartInput = z.infer + +/** + * What the provider sends the visitor back with. + * + * `code` and `state` are bounded but otherwise unconstrained: `state` is + * verified cryptographically by the API against the `--state-sso` cookie it + * minted, and re-deriving its format here would be a second, weaker copy of + * that check which breaks the flow the day the API's state generation changes. + * The caps exist so a crafted callback URL cannot make this app forward an + * unbounded string; every real provider's values fit inside them. + */ +export const ssoCallbackInputSchema = z.object({ + code: z.string().min(1).max(2048), + providerId: providerIdSchema, + state: z.string().min(1).max(2048), +}) + +export type SsoCallbackInput = z.infer + +/** + * ## The four results, and how the shared screens read them + * + * `{ ok: true }` or one failure from a closed list, so a component branches on + * a literal rather than on a status code or an API body. That is also the seam + * with `@vitnode/core`'s shared auth screens, whose props speak the legacy + * vocabulary: the caller wiring a screen to these translates once, at the call + * site, and nothing here has to know which UI it is feeding. + * + * signIn { ok: true } -> navigate + * { reason: 'access_denied' } -> `signInFormOutcome`'s + * `{ message: 'access_denied' }` + * { reason: 'server_error' } -> `{ message: 'Internal Server Error' }` + * + * completeSso { ok: true } -> navigate + * { reason: 'email_exists' } -> `SSOCallbackFailure` `'email_exists'` + * anything else -> `SSOCallbackFailure` `'unknown'` + * + * The extra reasons exist because the API distinguishes them and throwing that + * away here would be irreversible: `invalid_state` is a round trip that expired + * or was tampered with (start over), `unknown_provider` is an adapter this + * install does not have configured (a deployment mistake, not a visitor's). A + * screen that only has two states collapses them on the way in. + */ +export type SignInResult = + { ok: false; reason: 'access_denied' | 'server_error' } | { ok: true } + +export type SignOutResult = { ok: false; reason: 'server_error' } | { ok: true } + +export type SsoStartResult = + | { ok: false; reason: 'server_error' | 'unknown_provider' } + | { ok: true; url: string } + +export type CompleteSsoResult = + | { + ok: false + reason: + 'email_exists' | 'invalid_state' | 'server_error' | 'unknown_provider' + } + | { ok: true } + +/** + * Whether a reply's cookies may be copied onto this app's response. + * + * 2xx only, which is the rule Next's `fetcher()` applies to `allowSaveCookies` + * and therefore the rule the legacy flow was built on. It matters in both + * directions: the session cookie arrives on a 201 and the deletion arrives on a + * 200, while a 403 sign-in attempt has nothing this app should be writing to + * the visitor's browser. + */ +export const shouldSaveApiCookies = (status: number): boolean => + status >= 200 && status < 300 + +/** + * Whether a session response can be read as a session at all. + * + * The distinction this app got wrong for a whole stage, and the reason it is a + * named function rather than an inline `!== 200`. Two things arrive on the same + * wire and mean opposite things: + * + * 200 + { user: null } the visitor is genuinely nobody + * 429, 500, unreachable we do not know who the visitor is + * + * Collapsing the second into the first signs people out during a rate-limit + * spike: the guard on a protected route reads `user: null`, believes it, and + * redirects a signed-in visitor to the login page. So anything that is not a + * `200` is a *failed read*, which the caller has to raise rather than answer. + * + * `200` is the session route's only declared success (`users/session.route.ts` + * documents exactly one response), so this is the whole rule. + */ +export const isUsableSessionStatus = (status: number): boolean => status === 200 + +/** + * The sign-in route answers `201` with the session cookie attached, `403` when + * the address is unknown or the password is wrong, and nothing else on purpose. + * Everything unexpected - a 429 from the rate limiter, a 500 - is one + * `server_error`, the same collapse the legacy action made, because a sign-in + * form has exactly two things to say. + */ +export const signInResultFromStatus = (status: number): SignInResult => { + if (status === 201) return { ok: true } + if (status === 403) return { ok: false, reason: 'access_denied' } + + return { ok: false, reason: 'server_error' } +} + +/** + * Sign-out is `200` or it did not happen. The API deletes the cookie even when + * it finds no session to delete, so a `200` is the only outcome a working + * request has. + */ +export const signOutResultFromStatus = (status: number): SignOutResult => + status === 200 ? { ok: true } : { ok: false, reason: 'server_error' } + +/** + * An absolute `http(s)` URL - the only kind a caller may put a browser at. + * + * The value comes from this install's own SSO adapter, so this is not a trust + * boundary so much as a guarantee about what leaves here: the caller performs a + * full-document navigation to it, and a relative or `javascript:` URL reaching + * that assignment is an XSS sink. Checked where the URL is turned into a result, + * so a broken adapter is a `server_error` rather than a navigation. + */ +export const isProviderRedirectUrl = (value: unknown): value is string => { + if (typeof value !== 'string') return false + + let url: URL + try { + url = new URL(value) + } catch { + return false + } + + return url.protocol === 'http:' || url.protocol === 'https:' +} + +/** + * The provider's authorization URL, or why there is none. `404` is the API's + * answer for a provider this install does not have configured. + */ +export const ssoStartResultFromStatus = ( + status: number, + url: unknown, +): SsoStartResult => { + if (status === 404) return { ok: false, reason: 'unknown_provider' } + if (status !== 200 || !isProviderRedirectUrl(url)) { + return { ok: false, reason: 'server_error' } + } + + return { ok: true, url } +} + +/** + * The callback route's outcomes. + * + * `409` is the one a visitor can act on: the provider's email already belongs to + * an account that was not created through it, so the answer is to sign in with a + * password instead - which is what the legacy 409 screen offers. `400` is the + * API rejecting the OAuth `state`, which means the round trip was tampered with + * or simply took too long, and starting over is the only way forward. + * + * Reachable only for a callback that already passed + * {@link parseSsoCallback}, which is why a missing `code` is not one of these. + */ +export const completeSsoResultFromStatus = ( + status: number, +): CompleteSsoResult => { + if (status === 200) return { ok: true } + if (status === 400) return { ok: false, reason: 'invalid_state' } + if (status === 404) return { ok: false, reason: 'unknown_provider' } + if (status === 409) return { ok: false, reason: 'email_exists' } + + return { ok: false, reason: 'server_error' } +} + +export type ParsedSsoCallback = + | { + ok: false + reason: 'access_denied' | 'invalid_callback' | 'provider_error' + } + | { ok: true; params: SsoCallbackInput } + +const ssoCallbackQuerySchema = z.object({ + code: z.string().optional(), + error: z.string().optional(), + state: z.string().optional(), +}) + +/** `URLSearchParams` and a plain search object, as one shape to validate. */ +const asQueryRecord = (query: unknown): unknown => + query instanceof URLSearchParams ? Object.fromEntries(query) : query + +/** + * What the provider put in the callback URL, judged before any of it is sent on. + * + * The provider decides which half of the query it sends - `code` and `state` + * when the visitor approved, `error` when they did not - so the caller cannot + * know which shape it has without asking. The error branch comes first because + * OAuth allows both to be present and the error is the meaningful one. + * + * `error` is *classified*, never carried through: `access_denied` is the visitor + * declining at the provider and gets its own screen, and everything else becomes + * one `provider_error`, so no provider-authored string reaches the UI. + */ +export const parseSsoCallback = ({ + providerId, + query, +}: { + providerId: unknown + query: unknown +}): ParsedSsoCallback => { + const parsedQuery = ssoCallbackQuerySchema.safeParse(asQueryRecord(query)) + const { code, error, state } = parsedQuery.success ? parsedQuery.data : {} + + if (error !== undefined && error !== '') { + return { + ok: false, + reason: error === 'access_denied' ? 'access_denied' : 'provider_error', + } + } + + const params = ssoCallbackInputSchema.safeParse({ code, providerId, state }) + if (!params.success) return { ok: false, reason: 'invalid_callback' } + + return { ok: true, params: params.data } +} diff --git a/apps/web/src/lib/auth/mutations.ts b/apps/web/src/lib/auth/mutations.ts new file mode 100644 index 000000000..f14bc6bca --- /dev/null +++ b/apps/web/src/lib/auth/mutations.ts @@ -0,0 +1,98 @@ +import { createServerFn } from '@tanstack/react-start' + +import { + signInInputSchema, + signOutInputSchema, + ssoCallbackInputSchema, + ssoStartInputSchema, +} from '#/lib/auth/contract' +import { + completeSsoOnApi, + signInOnApi, + signOutOnApi, + startSsoOnApi, +} from '#/server/auth.server' + +/** + * The auth mutations, as this app's only way to change who is signed in. + * + * browser -> server function -> fetcherServer -> Hono users API + * | + * browser <- saveApiCookies <- Set-Cookie + * + * `createServerFn`, not the `createIsomorphicFn` the public reads use, and the + * difference is not stylistic. A session is a `Set-Cookie` on the API's reply to + * *this server*, so something on this server has to copy it onto the response the + * browser is actually reading - which is what `saveApiCookies` does, and what a + * browser-side fetch could never arrange. The visitor's current cookies have to + * travel the other way for the same reason: sign-out identifies the session to + * end by the cookie the request arrives with. + * + * Every one of them is a `POST`, which is what puts them behind the + * `createCsrfMiddleware` in `src/start.ts`: without it these would be + * unauthenticated endpoints that sign people in and out of this app from another + * origin's page. + * + * Each has an explicit validator, because the input is a browser's and the + * values reach an API path (`providerId`) and a credential check (`email`, + * `password`). Untrusted until parsed - see `#/lib/auth/contract`, which holds + * the schemas and the finite results, and is pure so both can be tested without + * a server. + * + * None of them redirects, and none of them touches cached state. They answer + * with a small closed result; the caller navigates - `#/lib/auth/return-to` + * decides where a post-login target is allowed to point - and the caller + * invalidates the session it holds. + * + * The bodies live in `#/server/auth.server`, which is what keeps the request + * scope and its `server-only` marker out of the browser bundle: they are + * referenced only inside these handlers, and Start's compiler removes a handler + * body - and the imports left unused with it - from the client build. + */ + +/** + * Signs a visitor in. + * + * `{ ok: true }` means the session cookie is on the response this call is + * answering with, so the very next request from this browser is signed in. + * `access_denied` is a wrong address or password; every other outcome is + * `server_error`. + */ +export const signIn = createServerFn({ method: 'POST' }) + .validator(signInInputSchema) + .handler(async ({ data }) => await signInOnApi(data)) + +/** + * Ends the current session. The reply carries the cookie deletion, so the + * caller's next request is anonymous - it still has to invalidate whatever it + * cached about the visitor. + */ +export const signOut = createServerFn({ method: 'POST' }) + .validator(signOutInputSchema) + .handler(async ({ data }) => await signOutOnApi(data)) + +/** + * Begins an SSO sign-in and returns the provider's authorization URL. + * + * The URL is returned rather than redirected to. The provider is another origin, + * so leaving here is a full-document navigation the caller performs + * (`window.location.assign(url)`); a router redirect cannot express that. The + * URL is checked to be `http(s)` before it is handed over, since the caller puts + * a browser at it. + */ +export const startSso = createServerFn({ method: 'POST' }) + .validator(ssoStartInputSchema) + .handler(async ({ data }) => await startSsoOnApi(data)) + +/** + * Completes an SSO sign-in, exchanging the provider's `code` for a session. + * + * Give it `parseSsoCallback(...)`'s `params`: a callback that came back with + * `error=` instead of a `code` never needs to reach the API, and reading the + * query belongs to the route that has it. `state` is passed through untouched + * for the API to verify against the cookie it minted - this app neither + * generates nor re-checks it. + */ +export const completeSso = createServerFn({ method: 'POST' }) + .validator(ssoCallbackInputSchema) + .handler(async ({ data }) => await completeSsoOnApi(data)) diff --git a/apps/web/src/lib/auth/query.ts b/apps/web/src/lib/auth/query.ts new file mode 100644 index 000000000..c95a364f9 --- /dev/null +++ b/apps/web/src/lib/auth/query.ts @@ -0,0 +1,154 @@ +import type { QueryClient } from '@tanstack/react-query' + +import { queryOptions } from '@tanstack/react-query' + +import type { SessionApi } from '#/lib/session' + +import { getSession } from '#/lib/session' + +import type { AuthState } from './shared' + +import { authStateFromSession, SESSION_QUERY_KEY } from './shared' + +/** + * The session, owned by the QueryClient the router already owns. + * + * There is no new client and no provider here, and that is the design: Stage 2 + * put one `QueryClient` in the router context, created once per server request + * and once in the browser (`src/router.tsx`). That lifetime is exactly what a + * session needs - per request on the server, so one visitor's session can never + * be rendered into another's page, and long-lived on the client, so navigating + * does not re-ask. A module-level `let session` would get the first half + * catastrophically wrong. + * + * Which also means the SSR dehydration carries the visitor's own session into + * the visitor's own HTML. Correct, and worth stating: that document is + * personalised and must not be served from a shared cache. + */ + +/** + * How long the router may trust a cached session before asking again. + * + * Not zero, because of preloading. The router runs with + * `defaultPreload: 'intent'` and `defaultPreloadStaleTime: 0`, so hovering a + * link runs that route's `beforeLoad`; with no stale window, every hovered link + * costs a round trip for an answer that has not changed. + * + * Not `Infinity` either. A session can end somewhere this tab will never hear + * about - the cookie expires, an admin revokes it, the visitor signs out in + * another tab - and with no expiry the UI would believe in it until a reload. + * Being wrong for half a minute costs a stale header, or one navigation into a + * page whose data the API then refuses; it cannot cost private data, because the + * API is the boundary and it re-reads the cookie every time. + * + * Sign-in and sign-out do not wait this out - they replace the value outright. + */ +const SESSION_STALE_TIME = 30_000 + +/** + * The visitor's session, as the app's one query definition. + * + * Every caller goes through this - a `beforeLoad` guard, a loader warming the + * cache, a component reading it back - so all of them share one cache entry and + * one fetch. Two definitions of the same read would be two entries, and the one + * a guard filled would not be the one the header renders from. + * + * `getSession` is a `createServerFn`, so the fetch happens on the server both + * times: directly during SSR, and over same-origin RPC on client navigation, + * which is what carries the visitor's cookies to a place that may read them. + * The browser never talks to the session endpoint itself. + * + * ## It asks once + * + * `retry: false`, which is a deliberate departure from Query's default of three + * attempts with backoff. This read is a route guard, not background content: it + * runs inside `beforeLoad`, and a navigation is blocked for as long as it takes. + * + * Retrying makes every failure worse in the same way. A `429` from the rate + * limiter is answered by sending the same request two more times, which is the + * thing the limiter is asking this app to stop doing; a `500` turns one round + * trip into three before the route can show anything, so a navigation appears to + * hang rather than to fail. Neither retry can succeed at anything the first one + * could not - the session is whatever the cookie says, and asking again does not + * change it. + * + * So one attempt, and a failure surfaces immediately as a query error, which the + * route's error path already handles. The visitor retries by reloading or + * navigating again, which is a decision they can make and a rate limiter can see + * coming. This is emphatically *not* a return to reading a failure as + * `user: null` - see `#/lib/session`, which rejects rather than inventing a + * guest. + */ +export const sessionQueryOptions = () => + queryOptions({ + queryFn: async () => await getSession(), + queryKey: SESSION_QUERY_KEY, + retry: false, + staleTime: SESSION_STALE_TIME, + }) + +/** + * The auth state, fetching the session first if this client has not read it yet. + * + * What a route's `beforeLoad` calls, and the only function it needs. It is safe + * to call on a preload: `ensureQueryData` is a read that fills a cache entry - + * it cannot create or end a session, and the API call behind it is a `GET` whose + * `Set-Cookie` this app deliberately does not save (`saveApiCookies` is for + * responses to sign-in, not to a session read). Two routes guarding themselves + * during one navigation share the single in-flight request. + * + * Returns the decision material and leaves the decision to the caller. No + * `redirect()` here on purpose: where a blocked visitor is sent is a property of + * the route that blocked them, so it belongs in the route tree - which is also + * the only layer that should be importing the router. + * + * ## It resolves only when the session is actually known + * + * An {@link AuthState} is returned when - and only when - the session query + * succeeded. `getSession` rejects if the session could not be read at all (a + * 429, a 500, an unreachable API), so `ensureQueryData` rejects and so does + * this. That is deliberate and it is the whole point of the contract: + * `authStateFromSession` describes two *known* states, and there is no third + * value for "we could not find out". + * + * A caller must therefore not treat a rejection as "signed out". Only + * `auth.isAuthenticated === false` means that. A rejection propagating out of a + * `beforeLoad` is an ordinary route error and takes the router's normal error + * path, which is what leaves a signed-in visitor on the page they asked for + * instead of bouncing them to the login form during an outage. + */ +export const ensureAuthState = async ( + queryClient: QueryClient, +): Promise => + authStateFromSession(await queryClient.ensureQueryData(sessionQueryOptions())) + +/** + * Replace the cached session with one the server just answered with. + * + * For sign-in and sign-out, which learn the new session as part of their own + * response: writing it here means the next render is already right, with no + * round trip in between and no frame showing the previous visitor. + * + * The key comes from `sessionQueryOptions()` rather than being spelled out, so + * TanStack Query checks the value against what the query is declared to return. + */ +export const setSessionData = ( + queryClient: QueryClient, + session: SessionApi, +): void => { + queryClient.setQueryData(sessionQueryOptions().queryKey, session) +} + +/** + * Mark the cached session stale and let the next reader fetch the truth. + * + * The other half of the pair above, for the cases where the client cannot know + * the new session: an SSO callback, a profile change, a sign-out whose response + * only says it worked. Invalidating rather than clearing keeps the current + * answer on screen while the fresh one is fetched, instead of blanking every + * component that reads the session. + */ +export const invalidateSession = async ( + queryClient: QueryClient, +): Promise => + await queryClient.invalidateQueries({ queryKey: SESSION_QUERY_KEY }) diff --git a/apps/web/src/lib/auth/redirects.ts b/apps/web/src/lib/auth/redirects.ts new file mode 100644 index 000000000..06f3da040 --- /dev/null +++ b/apps/web/src/lib/auth/redirects.ts @@ -0,0 +1,144 @@ +import { sanitizeReturnTo } from './return-to' + +/** + * Where the auth flow sends people, as pure data. + * + * Two directions and one rule each: + * + * anonymous at /settings -> /login?returnTo=/settings (returnToFor) + * signed in at /login -> /settings (postAuthDestination) + * + * Nothing here navigates, and nothing here imports the router. Every function is + * a string transform, which is what lets the whole redirect policy - including + * the two ways it can go wrong - be stated as a table rather than exercised + * through a browser. + * + * `#/lib/auth/return-to` is the security half: it decides whether a target is an + * application-relative path at all, and rejects every origin, scheme and + * control-character spelling. This module builds on that answer and adds the two + * things that are about *this* flow rather than about safety in general - the + * loop guard below, and the shape a TanStack redirect wants. + */ + +/** + * The login page's internal path - what the route tree matches, with no locale + * in it. + * + * `/login` and `/pl/login` are the same route; Stage 3's rewrite strips the + * prefix before matching and writes it back into every href the router builds. + * So this constant is deliberately un-prefixed, and nothing in the auth flow + * concatenates a language onto it. + */ +export const LOGIN_PATH = '/login' + +/** The search parameter carrying where a blocked visitor was heading. */ +export const RETURN_TO_PARAM = 'returnTo' + +/** + * A base for `URL` to resolve an already-validated path against. Never + * requested; only `pathname`, `search` and `hash` are read back off it. + */ +const RELATIVE_BASE = 'https://vitnode.invalid' + +/** A destination in the shape TanStack Router's `redirect`/`navigate` take. */ +export interface InternalDestination { + hash?: string + search?: Record + to: string +} + +/** + * A validated path, split into the fields a router navigation takes. + * + * **Not `href`.** A redirect carrying `href` is used verbatim + * (`Router.resolveRedirect` short-circuits on it), so it never reaches + * `buildLocation` and never runs the locale rewrite - a Polish visitor signing + * in at `/pl/login` would land on the English `/discover`. Split into + * `to`/`search`/`hash`, the same navigation goes through `buildLocation`, the + * rewrite writes the prefix back, and no code here has to know a language + * exists. + * + * Repeated search keys collapse to the last one. A `returnTo` is a link + * somebody clicked, not a form post, and every VitNode page reads its + * parameters singly. + */ +export const parseInternalDestination = ( + target: string, +): InternalDestination => { + const url = new URL(target, RELATIVE_BASE) + const search = Object.fromEntries(url.searchParams) + const hash = url.hash.slice(1) + + return { + ...(hash ? { hash } : {}), + ...(Object.keys(search).length > 0 ? { search } : {}), + to: url.pathname, + } +} + +/** The path part of an already-normalised target, without its query or hash. */ +const pathnameOf = (target: string): string => + new URL(target, RELATIVE_BASE).pathname + +/** + * Whether a target points back at the login page - or anything under it. + * + * The loop guard, and the one failure mode this module exists to prevent: + * `/login?returnTo=/login` sends a signed-in visitor to the login page, whose + * guard sends them to `/login`, forever. `/login/sso/google` is caught by the + * same rule, because finishing an OAuth round trip that has already completed is + * the same loop wearing a provider's name. + */ +const isLoginTarget = (target: string): boolean => { + const pathname = pathnameOf(target) + + return pathname === LOGIN_PATH || pathname.startsWith(`${LOGIN_PATH}/`) +} + +/** + * Where a visitor who is already signed in should go instead of the login page. + * + * Total: every input has an answer, and the answer is always somewhere this app + * may send a browser. `sanitizeReturnTo` rejects anything that names an origin + * or a scheme and falls back to `/`; the loop guard then rejects the login page + * itself. + * + * The result is an *internal* path. It is handed to the router, which applies + * the locale prefix on the way out. + */ +export const postAuthDestination = (returnTo: unknown): string => { + const target = sanitizeReturnTo(returnTo) + + return isLoginTarget(target) ? sanitizeReturnTo(undefined) : target +} + +/** + * The `returnTo` to attach when bouncing an anonymous visitor to the login page, + * or nothing. + * + * Built from the *internal* location - the path the route tree matched, with the + * locale already stripped - so the value that survives a round trip through the + * URL carries no language, and the prefix is written back exactly once, by the + * rewrite, when the router builds the link home. + * + * `undefined` for the front page, because `?returnTo=/` is the default spelled + * out: it makes the login URL longer and changes nothing. + */ +export const returnToFor = ({ + hash = '', + pathname, + searchStr = '', +}: { + hash?: string + pathname: string + searchStr?: string +}): string | undefined => { + const suffix = `${searchStr}${hash && !hash.startsWith('#') ? `#${hash}` : hash}` + const target = sanitizeReturnTo(`${pathname}${suffix}`) + + if (target === sanitizeReturnTo(undefined) || isLoginTarget(target)) { + return undefined + } + + return target +} diff --git a/apps/web/src/lib/auth/return-to.ts b/apps/web/src/lib/auth/return-to.ts new file mode 100644 index 000000000..2204e5ef3 --- /dev/null +++ b/apps/web/src/lib/auth/return-to.ts @@ -0,0 +1,98 @@ +/** + * Where a visitor goes after signing in, when the URL asked for somewhere + * specific. + * + * A pure string transform - no router, no request, no `window` - because the + * value it judges is the most attacker-reachable input in the whole auth flow: + * anyone can put `?returnTo=` on a link to the login page, and whatever comes + * out of here is handed to a navigation. So the rule is deliberately narrow: + * an *application-relative path*, or the fallback. Never a value that can name + * an origin, and never a scheme. + * + * That is what stops the two classic bugs at once - an open redirect + * (`?returnTo=https://evil.example.com`, which turns this site's login into a + * credible phishing hop) and a script URL (`?returnTo=javascript:...`, which is + * an XSS sink the moment anything assigns it to `location`). + * + * Nothing here performs a redirect. It answers "is this somewhere I may send + * you, and spelled how?" and the caller navigates. + */ + +/** Where an absent or rejected target lands: this site's root. */ +export const DEFAULT_RETURN_TO = '/' + +/** + * A base for `URL` to resolve a path against. + * + * `.invalid` is reserved by RFC 2606 precisely so it can never be a real host, + * which is what makes the origin comparison below meaningful: a value that + * carries an origin of its own - `https://evil.example.com`, + * `//evil.example.com` - resolves to *that* origin instead of this one, and is + * spotted by the two no longer matching. + */ +const SENTINEL_ORIGIN = 'http://return-to.invalid' + +/** + * Characters no accepted target may carry raw. + * + * Whitespace and C0/C1 controls because browsers *strip* tab, newline and + * carriage return out of a URL before parsing it, so `/\tjavascript:x` and + * `java\nscript:x` are two spellings of one thing and only one of them looks + * suspicious. A backslash because the URL parser treats `\` as `/` in a special + * scheme, which makes `/\evil.example.com` a protocol-relative URL wearing a + * disguise. Rejecting all of them outright is cheaper than trying to out-guess + * the normalisation, and a legitimate path needs none of them - it spells them + * percent-encoded. + */ +const REJECTED_CHARACTERS = /[\s\\]|\p{Cc}/u + +/** + * The canonical spelling of an acceptable target, or `null`. + * + * The string checks come first and do the real work: a target must begin with a + * single `/`, which excludes every scheme (`javascript:`, `data:`, `https:`) and + * every protocol-relative host (`//evil.example.com`) before a parser is + * involved at all. `URL` then normalises what survives - resolving `.` and `..`, + * percent-encoding what has to be - and the origin check is the backstop for + * any spelling the string checks did not anticipate. + */ +const normalize = (value: string): null | string => { + if (!value.startsWith('/') || value.startsWith('//')) return null + if (REJECTED_CHARACTERS.test(value)) return null + + let url: URL + try { + url = new URL(value, SENTINEL_ORIGIN) + } catch { + return null + } + + if (url.origin !== SENTINEL_ORIGIN) return null + + // Rebuilt from the parsed parts rather than returned as given, so the caller + // navigates to what was actually validated. The locale prefix rides along in + // the pathname untouched: `/pl/discover` is just a path here, which is why + // this needs to know nothing about languages. + return `${url.pathname}${url.search}${url.hash}` +} + +/** Whether `value` is a target {@link sanitizeReturnTo} would keep. */ +export const isSafeReturnTo = (value: unknown): value is string => + typeof value === 'string' && normalize(value) !== null + +/** + * `value` as a path this app may navigate to, or `fallback`. + * + * Total by construction: every input has an answer, so a caller never has to + * branch on "was it valid" before navigating. An unusable `fallback` is held to + * the same rule and degrades to {@link DEFAULT_RETURN_TO} rather than being + * trusted for having been passed in code. + */ +export const sanitizeReturnTo = ( + value: unknown, + { fallback = DEFAULT_RETURN_TO }: { fallback?: string } = {}, +): string => { + const target = typeof value === 'string' ? normalize(value) : null + + return target ?? normalize(fallback) ?? DEFAULT_RETURN_TO +} diff --git a/apps/web/src/lib/auth/screens.ts b/apps/web/src/lib/auth/screens.ts new file mode 100644 index 000000000..0dd596d95 --- /dev/null +++ b/apps/web/src/lib/auth/screens.ts @@ -0,0 +1,101 @@ +import type { SignInMutationResult } from '@vitnode/core/views/auth/sign-in/form/schema' +import type { SSOStartResult as SsoButtonFeedback } from '@vitnode/core/views/auth/sso/buttons/sso-buttons-content' +import type { SSOCallbackResult } from '@vitnode/core/views/auth/sso/callback/sso-callback-result' + +import type { + CompleteSsoResult, + SignInResult, + SsoStartResult, +} from '#/lib/auth/contract' +import type { SessionApi } from '#/lib/session' + +/** + * The auth contract, in the vocabulary the shared screens speak. + * + * #/lib/auth/contract @vitnode/core/views/auth + * { ok: false, reason } -> { message } | { failure } + * + * Two vocabularies exist on purpose and neither is wrong. The contract is a + * closed union over what the API can answer, with the distinctions the API makes + * kept (`invalid_state` is not `unknown_provider`); the screens speak the legacy + * shape they were extracted from, and are rendered unchanged by both frameworks. + * Something has to translate, and Agent A's contract says where: once, at the + * call site. This is that call site, pulled out of the hooks so it is a set of + * total functions over finite unions - checkable exhaustively, with no server, + * no router and no React. + * + * Every collapse below loses information deliberately. A visitor cannot act on + * the difference between "the OAuth state expired" and "that provider is not + * configured": both mean start over, and the screen for both is the same. The + * distinction survives where it is useful - in a server log - because nothing + * here throws it away before `#/server/auth.server` has recorded it. + */ + +/** + * A sign-in attempt, as `SignInFormContent` reads it. + * + * `undefined` is success: the shared form treats "nothing to report" as "the + * caller is navigating", which is exactly what the sign-in action then does. + * `access_denied` becomes the alert above the fields; everything else becomes + * the internal-error toast. + */ +export const signInFormResult = ( + result: SignInResult, +): SignInMutationResult => { + if (result.ok) return undefined + + return result.reason === 'access_denied' + ? { message: 'access_denied' } + : { message: 'Internal Server Error' } +} + +/** + * Starting an SSO flow, as `SSOButtonsContent` reads it. + * + * A message means the row raises the internal-error toast. Success returns + * nothing *here* because the caller has a browser to send somewhere - the + * provider's authorization URL - and that is not a value the button row can do + * anything with. + * + * The reason travels as the message even though the row does not print it: the + * row renders one fixed sentence, so the string is only ever read in a + * devtools network panel, and `unknown_provider` there is worth having. + */ +export const ssoStartFeedback = (result: SsoStartResult): SsoButtonFeedback => + result.ok ? undefined : { message: result.reason } + +/** + * Finishing an SSO round trip, as `useSSOCallback` reads it. + * + * `email_exists` is the one outcome with a screen of its own - the provider's + * address already belongs to an account, and the visitor is offered the password + * login instead. Everything else is one `unknown`, which is the shared + * component's entire remaining vocabulary. + */ +export const ssoCallbackResult = ( + result: CompleteSsoResult, +): SSOCallbackResult => { + if (result.ok) return {} + + return result.reason === 'email_exists' + ? { failure: 'email_exists' } + : { failure: 'unknown' } +} + +/** + * The session a signed-out visitor has. + * + * Written from the session already in hand rather than invented, so everything + * about the *installation* - which AI models are configured, and whatever else + * the session route grows - survives the sign-out, and only the visitor is + * removed. Building `{ ai: { models: [] }, user: null }` here instead would be a + * second, quietly diverging definition of the anonymous session. + * + * The point is the frame between "the API said it worked" and "the refetch came + * back": without this write that frame still renders the previous visitor's + * name. + */ +export const anonymousSession = (session: SessionApi): SessionApi => ({ + ...session, + user: null, +}) diff --git a/apps/web/src/lib/auth/shared.ts b/apps/web/src/lib/auth/shared.ts new file mode 100644 index 000000000..135703343 --- /dev/null +++ b/apps/web/src/lib/auth/shared.ts @@ -0,0 +1,168 @@ +import type { SessionApi } from '#/lib/session' + +/** + * Who is asking, as route state - and nothing else. + * + * Pure by construction: a type-only import of `SessionApi`, which TypeScript + * erases, so this module has no runtime dependencies at all. That is what lets + * the same rules run in the four places that cannot import each other's + * runtimes - a `beforeLoad` on the server, the same `beforeLoad` in the browser, + * a component reading the router context, and the tests - and it is why the + * query key is defined here rather than next to the query that uses it. + * + * ## What this layer is, and what it is not + * + * TanStack beforeLoad -> navigation and UI guard + * Hono authorization -> the security boundary + * + * Everything here is derived from a response the browser can read and, after + * hydration, from a cache the browser owns. It decides what to *render* and + * where to *navigate*. It decides nothing about what data anybody may read: + * every private read is authorized on the server by Hono, from the session + * cookie, in the route's own handler and middleware. If this state were ever + * treated as authoritative for API access, editing a cache entry in devtools + * would be a privilege escalation - which is why no VitNode endpoint asks the + * client who it is. + * + * ## Moderators are deliberately absent + * + * `session.user.isModerator` exists in the API's response and is hardcoded + * `false` (`users/routes/session.route.ts`: `// TODO: implement moderator + * role`). So there is no moderator authorization to model yet, and this state + * exposes no `isModerator` flag: a guard written against one would read as + * enforcement while being a constant, and would silently start granting access + * the day the API begins answering `true`. The field stays reachable as + * `auth.user.isModerator` for the shared header, which uses it to decide whether + * to draw a link - see `views/layouts/theme/header/user/auth/client.tsx`. + */ + +/** + * The signed-in visitor, as the API describes them. + * + * Derived from `SessionApi`, never written out again. The shape is a Zod schema + * in `api/modules/users/routes/session.route.ts` and reaches here through the + * fetcher's inference, so a field added or renamed there arrives without anybody + * editing this file. A hand-maintained copy is a second source of truth that + * typechecks perfectly while disagreeing with the server. + */ +export type AuthUser = NonNullable + +/** + * The auth state a route guard reads. + * + * A union rather than one object with four independent fields, so the states + * that cannot happen cannot be written: there is no guest holding + * `isAdmin: true`, and `if (auth.isAuthenticated)` narrows `auth.user` to + * non-null for everything inside the branch. That narrowing is most of the + * reason this type exists instead of routes poking at `session.user?.isAdmin` + * themselves. + * + * `session` is carried along because a page needs more of it than its guard + * does - the header renders the user, `ai.models` decides whether the assistant + * appears - and re-reading the cache to get it back would be a second answer + * that can disagree with the first. + */ +export type AuthState = + | { + isAdmin: boolean + isAuthenticated: true + session: SessionApi + user: AuthUser + } + | { + isAdmin: false + isAuthenticated: false + session: SessionApi + user: null + } + +/** + * The one cache entry a visitor's session lives in. + * + * Two segments and no third. In particular **no locale**: the session is who the + * visitor is, which does not change because they read the page in Polish, and a + * locale in the key would mean one visitor holding two sessions that are + * invalidated separately - so a sign-out on `/pl` would leave `/` still showing + * a signed-in header. Contrast `intlQueryPrefix` in `lib/i18n/query.ts`, where + * the locale belongs in the key because the *value* differs per language. + * + * Nothing else may be added either. A key that varies by route or by user id is + * a key the next caller cannot reconstruct, and the whole point of a single + * entry is that sign-in and sign-out know exactly what to replace. + */ +export const SESSION_QUERY_KEY = ['vitnode', 'session'] as const + +/** + * The session, as the auth state a guard reads. + * + * Total and pure: one argument in, one object out. No I/O, no router, no clock, + * no `window`. `beforeLoad` runs on hover under `defaultPreload: 'intent'`, so + * anything with a side effect here would be a side effect nobody asked for - + * this function cannot create a session, end one, or redirect, because it cannot + * do anything at all. + * + * `user === null` is the only test for "signed out", because that is the only + * thing the API promises: no cookie, an expired session and a rate-limited + * response all arrive as `{ user: null }` - see `lib/session.ts`, which + * normalises the non-200 case so callers never have to narrow. + */ +export const authStateFromSession = (session: SessionApi): AuthState => { + const { user } = session + + if (!user) { + return { isAdmin: false, isAuthenticated: false, session, user: null } + } + + return { isAdmin: user.isAdmin, isAuthenticated: true, session, user } +} + +/** + * The half of {@link AuthState} that has a visitor in it. + * + * Named so a guard can hand it downwards as route context: everything under + * `_authenticated` reads `context.auth.user` without a null check, because the + * boundary already made it. + */ +export type AuthenticatedState = Extract + +/** + * A page only a signed-in visitor may see - `/settings`, `/files`. + * + * A named predicate rather than `auth.isAuthenticated` at each call site: what a + * route declares is the *kind* of page it is, and what that requires is decided + * once, here. The Next.js app spells the same rule as + * `if (!session.user) notFound()` inside `LayoutSettings`. + * + * Written as a type predicate so the narrowing survives the call. A `boolean` + * would leave `auth.user` nullable on the *other* side of the guard, and every + * protected page would re-check something the boundary already proved - or, more + * likely, assert it away. + */ +export const canAccessAuthenticatedRoute = ( + auth: AuthState, +): auth is AuthenticatedState => auth.isAuthenticated + +/** + * A page that only makes sense signed *out* - `/login`, `/register`. + * + * The inverse of the rule above and not an independent one, so the two cannot + * drift into disagreeing about what "signed in" means. + */ +export const canAccessGuestRoute = (auth: AuthState): boolean => + !auth.isAuthenticated + +/** + * A route that is only offered to a visitor with admin permissions. + * + * `isAdmin` is computed server-side per request by `SessionAdminModel` + * `.checkIfUserIsAdmin`, against the staff permission tables, and is + * deliberately re-checked rather than cached - removing someone takes effect on + * their next request. + * + * Note what it does *not* mean. The AdminCP runs on a **second session**, its + * own cookie and its own sign-in (`getSessionAdminApi`, `SessionAdminModel`), so + * `isAdmin` says "this visitor may be offered the AdminCP", not "this visitor is + * inside it". Entry to the AdminCP is gated by that separate session on the + * server, and this predicate is not a substitute for it. + */ +export const canAccessAdminRoute = (auth: AuthState): boolean => auth.isAdmin diff --git a/apps/web/src/lib/middleware-config.ts b/apps/web/src/lib/middleware-config.ts new file mode 100644 index 000000000..ff7c41b21 --- /dev/null +++ b/apps/web/src/lib/middleware-config.ts @@ -0,0 +1,108 @@ +import type { middlewareModule } from '@vitnode/core/api/modules/middleware/middleware.module' +import type { routeMiddlewareSchema } from '@vitnode/core/api/modules/middleware/route' +import type { SSOProvider } from '@vitnode/core/views/auth/sso/providers' +import type { z } from 'zod' + +import { queryOptions } from '@tanstack/react-query' +import { createIsomorphicFn } from '@tanstack/react-start' +import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' +import { normalizeSSOProviders } from '@vitnode/core/views/auth/sso/providers' + +import { fetchMiddlewareConfigOnServer } from '#/server/middleware-config.server' + +/** + * What the auth screens need to know about *this installation*: which SSO + * adapters are registered, whether an email adapter exists to send a + * reset-password link, and the public captcha key. + * + * Derived from `vitnode.api.config.ts`, so it is the same answer for every + * visitor and only changes on deploy - which is what makes it a public, + * anonymous read rather than anything session-shaped. + */ +export type MiddlewareConfig = z.infer + +/** + * What the login page renders when the configuration cannot be read: the email + * and password fields, and nothing that depends on a configured adapter. + * + * Shared by both transports so a failure looks the same during SSR and after + * hydration, rather than the page changing shape when it rehydrates. + */ +export const ANONYMOUS_MIDDLEWARE_CONFIG: MiddlewareConfig = Object.freeze({ + isEmail: false, + sso: [], +}) + +const middleware = clientModule('@vitnode/core') + +/** + * The same read from the browser, for a client-side navigation into `/login`. + * + * Core's own browser fetcher, which is what the Next.js app's client components + * use - so a hydrated page and a Next.js page make the identical request. + */ +const fetchMiddlewareConfigInBrowser = async (): Promise => { + try { + const response = await fetcherClient(middleware, { + method: 'get', + module: 'middleware', + path: '/', + }) + + if (response.status !== 200) return ANONYMOUS_MIDDLEWARE_CONFIG + + return await response.json() + } catch { + return ANONYMOUS_MIDDLEWARE_CONFIG + } +} + +/** + * The transport boundary, and the reason one query definition works in a loader + * and in a component. + * + * Deliberately no `createServerFn` in between: this is a public, anonymous read + * that the API is already the boundary for, so routing it through a `POST` back + * to this app would cost two round trips instead of one. The same call the + * Discover feed makes, for the same reason. + * + * `createIsomorphicFn` is what makes that safe rather than merely tidy: the + * Start compiler keeps only the branch belonging to the bundle it is building + * and drops the other's import with it, so `middleware-config.server.ts` - and + * the `server-only` marker in it - never reaches the browser. + */ +const fetchMiddlewareConfig = createIsomorphicFn() + .server(fetchMiddlewareConfigOnServer) + .client(fetchMiddlewareConfigInBrowser) + +/** Everything a middleware-configuration cache entry's key starts with. */ +const MIDDLEWARE_QUERY_KEY = ['vitnode', 'middleware'] as const + +/** + * How long a cached copy is trusted. + * + * Deployment configuration changes on deploy and not otherwise, so this is + * generous on purpose: the login page and the SSO callback both read it, and + * navigating between them should not re-ask. It is not `Infinity` only so a + * long-lived tab eventually notices a deploy. + * + * No locale in the key: provider ids and names are configuration, not copy. + */ +const MIDDLEWARE_STALE_TIME = 300_000 + +export const middlewareConfigQueryOptions = () => + queryOptions({ + queryFn: async () => await fetchMiddlewareConfig(), + queryKey: MIDDLEWARE_QUERY_KEY, + staleTime: MIDDLEWARE_STALE_TIME, + }) + +/** + * The registered SSO providers, made safe to render. + * + * Core's own normaliser - the one the Next.js provider row uses - so a provider + * missing a name, or listed twice, produces the same button row in both + * frameworks. + */ +export const ssoProvidersOf = (config: MiddlewareConfig): SSOProvider[] => + normalizeSSOProviders(config.sso) diff --git a/apps/web/src/lib/migration-navigation.ts b/apps/web/src/lib/migration-navigation.ts new file mode 100644 index 000000000..4dda9d601 --- /dev/null +++ b/apps/web/src/lib/migration-navigation.ts @@ -0,0 +1,260 @@ +import type { AnyRouter } from '@tanstack/react-router' + +import { useRouter } from '@tanstack/react-router' + +import type { InternalDestination } from '#/lib/auth/redirects' +import type { Locale } from '#/lib/i18n/shared' + +import { parseInternalDestination } from '#/lib/auth/redirects' +import { useLocale } from '#/lib/i18n/client' +import { localeRouting } from '#/lib/i18n/shared' +import { buildLegacyHref, legacyWebOrigin } from '#/lib/legacy-app' + +/** + * Going somewhere in VitNode from code, while half of VitNode still runs on + * Next.js. + * + * `MigrationLink` answers this for a rendered link. This is the same rule for a + * navigation nobody clicked - the one a sign-in performs when it is finished, + * and the one the login page's guard performs for a visitor who is already + * signed in. Both hand it a path a visitor supplied (`?returnTo=`), and during a + * strangler migration most of those paths still belong to the other + * application: `/settings/security`, `/blog/post-30`, `/files/...`. Handing one + * of those to `router.navigate` routes it into *this* router, which has nothing + * to match it with, and a working page becomes a TanStack not-found. + * + * ## Safe and owned are different questions + * + * safe - may this app send a browser here at all? `sanitizeReturnTo` + * owned - which application currently serves it? `isTanStackOwnedPath` + * + * `/settings/security` is `safe: true, owned: false`, and that combination is + * the normal case rather than an edge one. Nothing here relaxes the first + * question to answer the second: a legacy navigation still begins from a + * validated application-relative path, and the origin it is resolved against + * comes from configuration, never from the URL. + * + * ## Deciding and doing are separate + * + * {@link migrationDestination} is pure - a path, an ownership answer, a locale + * and an origin in; one of two destinations out. It has to be, because the same + * decision is made in two environments that share no navigation API: a + * `beforeLoad` running on the server, where the answer becomes an HTTP redirect, + * and a click handler in the browser, where it becomes a router call. Only the + * execution differs, and only the execution is environment-specific. + * + * There is deliberately no list of migrated routes here. The route tree is the + * table - the same one `MigrationLink` reads - so a route migrated in a later + * stage starts being navigated to client-side without this file changing. + */ + +/** + * A base for parsing an href that carries no origin. Never requested, and never + * rendered - only `pathname`, `search` and `hash` are ever read back off it. + */ +const RELATIVE_BASE = 'https://vitnode.invalid' + +/** + * An href as the route tree sees it: the locale prefix removed, everything else + * untouched. + * + * Stage 3's own rule and nothing else - `deLocalizeUrl` rewrites the pathname + * and leaves the query and hash alone, and it already knows which paths carry no + * locale at all (`/admin`, `/api`). Writing a prefix check here instead would be + * a second copy of that rule, and the two would disagree the first time a + * language was added. + * + * Shared by the two questions that both need the internal spelling: whether this + * app owns a path, and what to hand the router once it does. + */ +const deLocalizeHref = (href: string): URL => + localeRouting.deLocalizeUrl(new URL(href, RELATIVE_BASE)) + +/** The API mount is not a page. */ +const isApiRouteId = (routeId: string): boolean => + routeId === '/api' || routeId.startsWith('/api/') + +/** A trailing slash is not a different page. `/` stays `/`. */ +const trimTrailingSlash = (pathname: string): string => + pathname.length > 1 && pathname.endsWith('/') + ? pathname.replace(/\/+$/, '') + : pathname + +/** + * Whether this app's route tree can render `href` itself. + * + * Four things have to happen before the answer is trusted, and each one is a way + * this returned the wrong answer while it was being written: + * + * 1. **Strip the query and hash.** `matchRoutes` takes a *pathname*; + * `/discover?a=1` matches nothing. + * 2. **De-localize.** The route tree has no locale in it - that is the whole of + * Stage 3 - so `/pl/discover` matches nothing until the prefix comes off. + * 3. **Reject the API mount.** See {@link isApiRouteId}. + * 4. **Insist the deepest match consumed the whole path.** See below. + * + * ## Why "something matched" is not enough + * + * `matchRoutes` matches a *branch*, not a leaf: given a path it cannot fully + * resolve, it answers with the deepest ancestor that does match and leaves the + * rest unconsumed. So `/login/reset-password` comes back as a match on `/login`, + * and `/discover/anything` as a match on `/discover` - and under a + * "matched.length > 0" rule both looked owned. That is not cosmetic: it hands a + * page the Next.js app still serves to this router as a client-side navigation, + * turning a working password reset into a TanStack not-found. + * + * Comparing the deepest match's own `pathname` to the requested one is the whole + * fix, and it is the router's own answer rather than a second opinion: a real + * match consumed the path (`/login/sso/google` matches at `/login/sso/google`), + * a partial one did not (`/login/reset-password` matches at `/login`). A path + * nothing matched resolves to the root alone, at `/`, and fails the same test. + * + * `src/tests/plugin-routes.test.ts` pins every one of these cases. + */ +export const isTanStackOwnedPath = ( + router: AnyRouter, + href: string, +): boolean => { + // The same rule `rewrite.input` applies, from the same Stage 3 helper - the + // rewrite is `deLocalizeUrl` and nothing else, so this is one rule, not a copy. + const { pathname } = deLocalizeHref(href) + + const matches = router.matchRoutes(pathname, undefined) as { + pathname: string + routeId: string + }[] + const deepest = matches.at(-1) + + if (!deepest || deepest.routeId === '__root__') return false + if (matches.some((match) => isApiRouteId(match.routeId))) return false + + return trimTrailingSlash(deepest.pathname) === trimTrailingSlash(pathname) +} + +/** + * Where a validated internal path actually leads, and by which mechanism. + * + * - `tanstack` - a route this app renders. Carries the path split into the + * fields a router navigation takes, so the Stage 3 rewrite runs and writes the + * locale prefix exactly once. + * - `legacy` - a route the Next.js app still serves. Carries a finished href, + * already localized and already pointed at the legacy origin, for a + * full-document navigation. + */ +export type MigrationDestination = + | { destination: InternalDestination; type: 'tanstack' } + | { href: string; type: 'legacy' } + +/** A validated href in the spelling the route tree uses, query and hash intact. */ +const internalHref = (href: string): string => { + const { hash, pathname, search } = deLocalizeHref(href) + + return `${pathname}${search}${hash}` +} + +/** + * The decision, with nothing environment-specific in it. + * + * `isOwned` is passed in rather than computed, which is what keeps this pure: + * answering it needs a live router, and this function is called from a + * `beforeLoad` on the server as well as from the browser. + * + * ## The locale is handled on exactly one branch, in opposite directions + * + * **Owned: strip it.** The route tree has no locale in it, so what the router is + * handed must not either - and then `rewrite.output` writes the prefix back when + * the location is built. That matters because `href` is user-supplied: + * `returnTo` is produced from an internal path in the normal flow, but nothing + * stops somebody visiting `/pl/login?returnTo=/pl/discover`, and + * `sanitizeReturnTo` accepts it because it is a perfectly safe application path. + * Passing `/pl/discover` through as `to` would ask the router to navigate to a + * route that does not exist under that name. + * + * **Legacy: keep it.** `buildLegacyHref` localizes the path itself, with the + * same Stage 3 rule, and that rule is idempotent - so an href that already + * carries a prefix keeps exactly one and `/pl/pl/...` is not a shape this can + * produce. De-localizing first would be work that function immediately undoes. + */ +export const migrationDestination = ({ + href, + isOwned, + legacyOrigin, + locale, +}: { + href: string + isOwned: boolean + legacyOrigin?: string + locale: Locale +}): MigrationDestination => + isOwned + ? { + destination: parseInternalDestination(internalHref(href)), + type: 'tanstack', + } + : { href: buildLegacyHref({ href, legacyOrigin, locale }), type: 'legacy' } + +/** + * A {@link MigrationDestination} as options for `redirect()` or + * `router.navigate()`, which take the same shape. + * + * `reloadDocument` is set explicitly on the legacy branch rather than left to be + * inferred. An absolute href infers it on its own, but `buildLegacyHref` + * legitimately returns a *relative* path when no legacy origin is configured - + * the deployment where a proxy routes both apps by path - and inferring nothing + * there would turn the one case that must leave this router into a client-side + * navigation to a route it cannot render. + */ +export const migrationNavigateOptions = (destination: MigrationDestination) => + destination.type === 'legacy' + ? { href: destination.href, reloadDocument: true } + : destination.destination + +/** + * {@link migrationDestination}, against this deployment's configured legacy + * origin. + * + * Still takes the ownership *answer* rather than a router, because the two + * callers get it from different places: a click handler has a mounted router, + * and a `beforeLoad` has `context.ownsPath` - the router's own answer, handed to + * the route tree by `src/router.tsx` because `beforeLoad` receives no router. + */ +export const resolveMigrationDestination = ({ + href, + isOwned, + locale, +}: { + href: string + isOwned: boolean + locale: Locale +}): MigrationDestination => + migrationDestination({ + href, + isOwned, + legacyOrigin: legacyWebOrigin(), + locale, + }) + +/** + * Navigate to a validated internal path, wherever it is actually served. + * + * The browser half of the rule. `router.navigate` performs both branches - given + * `reloadDocument` it does a full-document navigation, through the router's own + * blocker and dangerous-protocol checks - so there is one call and no + * `location.assign` reaching around the framework. + */ +export const useMigrationNavigate = () => { + const router = useRouter() + const locale = useLocale() + + return async (href: string): Promise => { + await router.navigate( + migrationNavigateOptions( + resolveMigrationDestination({ + href, + isOwned: isTanStackOwnedPath(router, href), + locale, + }), + ), + ) + } +} diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts index 1dfc3c9d0..a5cb58da4 100644 --- a/apps/web/src/lib/session.ts +++ b/apps/web/src/lib/session.ts @@ -3,6 +3,7 @@ import type { usersModule } from '@vitnode/core/api/modules/users/users.module' import { createServerFn } from '@tanstack/react-start' import { clientModule } from '@vitnode/core/lib/fetcher-client' +import { isUsableSessionStatus } from '#/lib/auth/contract' import { fetcherServer } from '#/server/fetcher.server' /** @@ -15,10 +16,36 @@ const users = clientModule('@vitnode/core') export type SessionApi = Awaited> +/** + * What the browser is told when the session could not be read. + * + * A fixed sentence and nothing else. An error thrown out of a server function + * is serialized back to the caller, and the errors this one catches are not fit + * to send: `rawApiFetch` throws on a 500 with the failing API URL and the + * server's own error text in the message. The detail is logged where a server + * log is the right place for it. + */ +export const SESSION_UNAVAILABLE = 'The session could not be read.' + /** * The signed-in visitor, or `{ user: null }` - the TanStack Start counterpart of * `@vitnode/core`'s `getSessionApi()`. * + * ## It rejects rather than inventing a guest + * + * `{ user: null }` means one thing only: the API answered, and nobody is signed + * in. A read that could not be *evaluated* - a 429 from the rate limiter, a 500, + * an API that is not listening - is an error, not an anonymous visitor. + * + * This used to return `{ ai: { models: [] }, user: null }` for every non-200, + * which signed people out during an outage: the guard on a protected route read + * the fabricated `user: null`, believed it, and redirected a signed-in visitor + * to the login page. Rejecting instead leaves the query in an error state, which + * is what TanStack Query is for, and the route's normal error path handles it. + * + * `rawApiFetch` already throws on a 500, so that case arrives here as an + * exception and is handled identically - one failure mode, not two. + * * A `createServerFn` rather than a route `loader`, because a loader also runs in * the browser on client-side navigation and there is no request to read there. * As a server function it runs on the server both times: directly during SSR, @@ -36,18 +63,28 @@ export type SessionApi = Awaited> * shape appears here, that per-render memoisation has to come with it. */ export const getSession = createServerFn().handler(async () => { - const response = await fetcherServer(users, { - method: 'get', - module: 'users', - path: '/session', - }) - - // A non-200 (a 429 from the rate limiter, say) carries something other than a - // session, so read it as "nobody is signed in" rather than crashing the render - // while parsing it. One shape either way, so callers never have to narrow. - if (response.status !== 200) { - return { ai: { models: [] }, user: null } - } + try { + const response = await fetcherServer(users, { + method: 'get', + module: 'users', + path: '/session', + }) - return await response.json() + if (isUsableSessionStatus(response.status)) return await response.json() + + // Caught immediately below. Thrown rather than returned so there is one + // failure path and one log line, and so the status reaches the log. + throw new Error(`the session route answered ${response.status}`) + } catch (error) { + // eslint-disable-next-line no-console + console.error(`[auth] ${SESSION_UNAVAILABLE}`, error) + + // No `cause`: this error is serialized back to the browser, and the one it + // would carry is `rawApiFetch`'s - the failing API URL and the server's own + // error text. It has just been written to the server log, which is where it + // belongs; attaching it here would publish it. This is the whole reason the + // message is a fixed sentence. + // eslint-disable-next-line preserve-caught-error + throw new Error(SESSION_UNAVAILABLE) + } }) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 343372be2..c4ed15692 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,53 +10,109 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as DiscoverRouteImport } from './routes/discover' +import { Route as LoginRouteImport } from './routes/login' +import { Route as AuthenticatedAccountRouteImport } from './routes/_authenticated/account' import { Route as ApiSplatRouteImport } from './routes/api/$' +import { Route as LoginSsoProviderIdRouteImport } from './routes/login_.sso.$providerId' const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const AuthenticatedRoute = AuthenticatedRouteImport.update({ + id: '/_authenticated', + getParentRoute: () => rootRouteImport, +} as any) const DiscoverRoute = DiscoverRouteImport.update({ id: '/discover', path: '/discover', getParentRoute: () => rootRouteImport, } as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const AuthenticatedAccountRoute = AuthenticatedAccountRouteImport.update({ + id: '/account', + path: '/account', + getParentRoute: () => AuthenticatedRoute, +} as any) const ApiSplatRoute = ApiSplatRouteImport.update({ id: '/api/$', path: '/api/$', getParentRoute: () => rootRouteImport, } as any) +const LoginSsoProviderIdRoute = LoginSsoProviderIdRouteImport.update({ + id: '/login_/sso/$providerId', + path: '/login/sso/$providerId', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/discover': typeof DiscoverRoute + '/login': typeof LoginRoute + '/account': typeof AuthenticatedAccountRoute '/api/$': typeof ApiSplatRoute + '/login/sso/$providerId': typeof LoginSsoProviderIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/discover': typeof DiscoverRoute + '/login': typeof LoginRoute + '/account': typeof AuthenticatedAccountRoute '/api/$': typeof ApiSplatRoute + '/login/sso/$providerId': typeof LoginSsoProviderIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/_authenticated': typeof AuthenticatedRouteWithChildren '/discover': typeof DiscoverRoute + '/login': typeof LoginRoute + '/_authenticated/account': typeof AuthenticatedAccountRoute '/api/$': typeof ApiSplatRoute + '/login_/sso/$providerId': typeof LoginSsoProviderIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/discover' | '/api/$' + fullPaths: + | '/' + | '/discover' + | '/login' + | '/account' + | '/api/$' + | '/login/sso/$providerId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/discover' | '/api/$' - id: '__root__' | '/' | '/discover' | '/api/$' + to: + | '/' + | '/discover' + | '/login' + | '/account' + | '/api/$' + | '/login/sso/$providerId' + id: + | '__root__' + | '/' + | '/_authenticated' + | '/discover' + | '/login' + | '/_authenticated/account' + | '/api/$' + | '/login_/sso/$providerId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AuthenticatedRoute: typeof AuthenticatedRouteWithChildren DiscoverRoute: typeof DiscoverRoute + LoginRoute: typeof LoginRoute ApiSplatRoute: typeof ApiSplatRoute + LoginSsoProviderIdRoute: typeof LoginSsoProviderIdRoute } declare module '@tanstack/react-router' { @@ -68,6 +124,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/_authenticated': { + id: '/_authenticated' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthenticatedRouteImport + parentRoute: typeof rootRouteImport + } '/discover': { id: '/discover' path: '/discover' @@ -75,6 +138,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DiscoverRouteImport parentRoute: typeof rootRouteImport } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/_authenticated/account': { + id: '/_authenticated/account' + path: '/account' + fullPath: '/account' + preLoaderRoute: typeof AuthenticatedAccountRouteImport + parentRoute: typeof AuthenticatedRoute + } '/api/$': { id: '/api/$' path: '/api/$' @@ -82,13 +159,35 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSplatRouteImport parentRoute: typeof rootRouteImport } + '/login_/sso/$providerId': { + id: '/login_/sso/$providerId' + path: '/login/sso/$providerId' + fullPath: '/login/sso/$providerId' + preLoaderRoute: typeof LoginSsoProviderIdRouteImport + parentRoute: typeof rootRouteImport + } } } +interface AuthenticatedRouteChildren { + AuthenticatedAccountRoute: typeof AuthenticatedAccountRoute +} + +const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { + AuthenticatedAccountRoute: AuthenticatedAccountRoute, +} + +const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( + AuthenticatedRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AuthenticatedRoute: AuthenticatedRouteWithChildren, DiscoverRoute: DiscoverRoute, + LoginRoute: LoginRoute, ApiSplatRoute: ApiSplatRoute, + LoginSsoProviderIdRoute: LoginSsoProviderIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 27157af6b..4c213e150 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -5,6 +5,7 @@ import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query import { createVitNodeQueryClient } from '@vitnode/core/lib/query-client' import { createLocaleRewrite } from './lib/i18n/client' +import { isTanStackOwnedPath } from './lib/migration-navigation' import { pluginRouteSpecs, withPluginRoutes } from './lib/plugin-routes' import { pluginRouteManifest } from './plugin-route-manifest.gen' import { pluginRouteModules } from './plugin-routes.gen' @@ -67,7 +68,26 @@ export function getRouter() { const holder: { current?: AnyRouter } = {} const router = createTanStackRouter({ - context: { queryClient }, + context: { + /** + * The route tree asked about itself, for the code that cannot ask + * directly. + * + * `beforeLoad` receives no router, and the login guard needs the same + * answer `MigrationLink` gets: is this destination one this app serves, or + * one the Next.js app still does? Handing the question through the context + * keeps the route tree as the single source of truth - there is still no + * list of migrated routes anywhere - and it is one boolean, not a + * navigation layer. + * + * `holder` again, for the same reason `rewrite` uses it: the context is + * built before the router exists, and this is only ever called from a + * `beforeLoad`, which is long afterwards. + */ + ownsPath: (href: string) => + holder.current ? isTanStackOwnedPath(holder.current, href) : false, + queryClient, + }, defaultPreload: 'intent', defaultPreloadStaleTime: 0, rewrite: createLocaleRewrite(() => holder.current), diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1389e3e48..c70856907 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -27,12 +27,18 @@ const { debug, i18n, metadata, theme } = vitNodeShellConfig /** * What the router itself provides, before any route has run. * - * Just the QueryClient. `beforeLoad` below adds `locale` on top, so what a - * loader actually receives is `{ queryClient, locale }` - the language included, - * because a loader that fetches anything user-facing needs to know which one it - * is fetching. + * The QueryClient, and the route tree's answer to "do I serve this path?". + * `beforeLoad` below adds `locale` on top, so what a loader actually receives is + * `{ ownsPath, queryClient, locale }` - the language included, because a loader + * that fetches anything user-facing needs to know which one it is fetching. + * + * `ownsPath` is here rather than derived per route because `beforeLoad` receives + * no router, and the login guard has to make the same migration decision + * `MigrationLink` makes for a rendered link. See `src/router.tsx`, which wires + * it, and `#/lib/migration-navigation`, which owns the rule. */ export interface RootRouterContext { + ownsPath: (href: string) => boolean queryClient: QueryClient } diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx new file mode 100644 index 000000000..c8fd57813 --- /dev/null +++ b/apps/web/src/routes/_authenticated.tsx @@ -0,0 +1,75 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +import { ensureAuthState } from '#/lib/auth/query' +import { LOGIN_PATH, returnToFor } from '#/lib/auth/redirects' +import { canAccessAuthenticatedRoute } from '#/lib/auth/shared' + +/** + * The boundary every page that requires a signed-in visitor sits under. + * + * Pathless - the leading underscore means it contributes no URL segment - so a + * route joins it by *where its file lives*, not by remembering to call a guard: + * `routes/_authenticated/settings.tsx` is `/settings`, guarded, and the guard is + * this file. That is the whole point of introducing it now, with nothing under + * it yet: Stage 8 moves `/settings/*` here and inherits the rule rather than + * writing a second copy of it. + * + * ## Why the check is in `beforeLoad` + * + * `beforeLoad` runs before the route's loader and long before React, so an + * anonymous visitor never receives a byte of a protected page - not a flash, not + * a hydration, not a `useEffect` that redirects afterwards. A component-level + * check would render the page first and then take it away, which is both a + * visible flicker and, on the server, protected markup already written into the + * stream. + * + * ## A failed session read is not a signed-out visitor + * + * `ensureAuthState` rejects when the session could not be read - a rate limit, a + * 500, an API that is not listening - and that rejection is deliberately left to + * propagate. Only `canAccessAuthenticatedRoute` answering `false`, on a session + * the API actually returned, sends anybody to the login page. Catching the + * rejection and redirecting would sign a visitor out because of an outage, which + * is precisely the bug this shape exists to prevent. + * + * ## What it is not + * + * A navigation guard, and only that. Every private read is authorized by Hono + * from the session cookie, in the API's own handlers - so a visitor who edits + * this app's cached session in devtools gets a page shell and an API that still + * refuses them. Nothing here is, or may become, the security boundary. See the + * long note in `#/lib/auth/shared`. + * + * ## What children receive + * + * `beforeLoad`'s return merges into the context of everything below, so a child + * route reads `context.auth` already narrowed to the signed-in half of the union + * - `auth.user` is non-null without a check. It is the same object the guard + * decided on, from the same cache entry, so a page cannot disagree with the + * guard that let it render. + */ +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: async ({ context, location }) => { + const auth = await ensureAuthState(context.queryClient) + + if (!canAccessAuthenticatedRoute(auth)) { + // TanStack Router's own control-flow signal: `redirect()` returns a + // typed redirect object that the router catches and turns into a + // navigation (or, during SSR, a 302). Throwing it is what stops the + // guard - and what narrows the code below. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ + search: { + // The *internal* path - the locale has already been stripped by the + // rewrite - so the value that round-trips through the login URL + // carries no language, and the prefix is written back exactly once, + // by the rewrite, when the router builds the way home. + returnTo: returnToFor(location), + }, + to: LOGIN_PATH, + }) + } + + return { auth } + }, +}) diff --git a/apps/web/src/routes/_authenticated/account.tsx b/apps/web/src/routes/_authenticated/account.tsx new file mode 100644 index 000000000..937eef949 --- /dev/null +++ b/apps/web/src/routes/_authenticated/account.tsx @@ -0,0 +1,103 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Button } from '@vitnode/core/components/ui/button' +import { formatPageTitle } from '@vitnode/core/lib/metadata' +import { useTranslations } from 'use-intl' + +import { useSignOutAction } from '#/lib/auth/actions' +import { vitNodeShellConfig } from '#/vitnode.shell.config' + +/** + * The Stage 6 verification page, and nothing more. + * + * The sibling of `routes/index.tsx`, which has served the same purpose since + * Stage 3: a page whose only job is to make the stage's runtime observable. No + * VitNode account feature is migrated here - `/settings` and its three tabs are + * still the Next.js app's, and Stage 8 moves them under this same + * `_authenticated` boundary. + * + * It exists for three reasons, and each one is a thing that would otherwise be + * unprovable until Stage 8: + * + * 1. **The guard has something to guard.** `_authenticated` is a pathless + * layout, and the route generator refuses a childless one - it infers `/` for + * it, which collides with the front page. So the boundary needs a first + * child, and a page that renders the session it was let in with is the + * smallest honest one. + * 2. **The redirect is real.** Anonymous, this URL answers + * `/login?returnTo=/account` - no protected markup is rendered first, because + * the decision is made in `beforeLoad`. + * 3. **Sign-out is wired.** This app mounts no header yet, so there is nowhere + * else a sign-out control could live without migrating the shell. The button + * below is the narrow alternative: it ends the session, replaces the cached + * one, and lets the guard above notice - which lands the visitor back on the + * login page, from the rule that owns that decision rather than from anything + * here. + * + * Delete it when a real account page arrives. + */ +export const Route = createFileRoute('/_authenticated/account')({ + // No loader and no `RouteMessages`: everything this page renders comes from + // `core.global`, which the root route already warms and provides. + head: () => ({ + meta: [{ title: formatPageTitle(vitNodeShellConfig.metadata, 'Account') }], + }), + component: AccountRoute, +}) + +function AccountRoute() { + /** + * The visitor, from the guard that let this page render. + * + * `context.auth` is `_authenticated`'s `beforeLoad` return, already narrowed + * to the signed-in half of the union - so `auth.user` needs no check here. It + * is the same object the guard decided on, read from the one canonical session + * entry, so this page cannot disagree with the rule that admitted it. + */ + const { auth } = Route.useRouteContext() + const t = useTranslations('core.global') + const signOut = useSignOutAction() + + return ( +
+
+

+ {auth.user.name} +

+ +

+ Behind the _authenticated boundary. Stage 8 moves + /settings here; this page is the scaffold that proves + the guard and the sign-out transition. +

+
+ +
+
+ + Email + + + {auth.user.email} + +
+ +
+ + Session - ends here, and the guard above notices + + + +
+
+
+ ) +} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx new file mode 100644 index 000000000..3a4bd1f61 --- /dev/null +++ b/apps/web/src/routes/login.tsx @@ -0,0 +1,241 @@ +import type { AbstractIntlMessages } from 'use-intl' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { createFileRoute, redirect } from '@tanstack/react-router' +import { formatPageTitle } from '@vitnode/core/lib/metadata' +import { SignInFormContent } from '@vitnode/core/views/auth/sign-in/form/sign-in-form-content' +import { SignInContent } from '@vitnode/core/views/auth/sign-in/sign-in-content' +import { SSOButtonsContent } from '@vitnode/core/views/auth/sso/buttons/sso-buttons-content' +import { createTranslator } from 'use-intl' +import { z } from 'zod' + +import { MigrationLink } from '#/components/migration-link' +import { RouteMessages } from '#/components/route-messages' +import { startSsoAction, useSignInAction } from '#/lib/auth/actions' +import { ensureAuthState } from '#/lib/auth/query' +import { postAuthDestination } from '#/lib/auth/redirects' +import { canAccessGuestRoute } from '#/lib/auth/shared' +import { intlQueryOptions } from '#/lib/i18n/query' +import { + middlewareConfigQueryOptions, + ssoProvidersOf, +} from '#/lib/middleware-config' +import { + migrationNavigateOptions, + resolveMigrationDestination, +} from '#/lib/migration-navigation' +import { vitNodeShellConfig } from '#/vitnode.shell.config' + +/** + * The login page - the first VitNode auth route to render outside Next.js. + * + * One route file serving `/login` and `/pl/login`: Stage 3's rewrite strips the + * prefix before matching and writes it back into every link the router builds, + * so nothing here mentions a language and there is no `/pl/login.tsx` to keep in + * step. The Next.js route at `packages/vitnode/src/routes/main/login/page.tsx` + * is still live and unchanged - this is a parallel slice until the cutover. + * + * Everything visible is shared: `SignInContent`, `SignInFormContent` and + * `SSOButtonsContent` are the same modules the Next.js page renders, handed the + * three things a shared component cannot resolve for itself - a `Link`, a way to + * sign in, and a way to start an SSO flow. + * + * ## What is deliberately *not* migrated + * + * `/register` and `/login/reset-password` stay on Next.js. They are reached + * through `MigrationLink`, which asks the route tree whether this app owns a + * destination and falls back to a document load into the legacy app - so + * nothing here hardcodes a second origin, and the day either route is migrated + * this file does not change. `src/tests/plugin-routes.test.ts` pins the other + * half of that: owning `/login` must not make `/login/reset-password` look + * owned, which is why the SSO callback is a *non-nested* sibling + * (`login_.sso.$providerId.tsx`) rather than a child. + */ + +/** + * What this page renders strings from. + * + * `core.global` is the shell's and the heading's, `core.auth.sign_in` is the + * card's and the form's, `core.auth.sso` is the provider row's. One list, read + * by both the loader that fetches them and the provider that mounts them, + * because they have to be the same set or the provider suspends on a key nobody + * warmed. + */ +const LOGIN_NAMESPACES = [ + 'core.global', + 'core.auth.sign_in', + 'core.auth.sso', +] as const + +/** + * Where a visitor was heading before the guard sent them here. + * + * Accepted as any string and judged where it is used, never here: whether a + * target is somewhere this app may navigate to is `sanitizeReturnTo`'s single + * answer, and duplicating it in a schema would be a second rule that can + * disagree with the first. Rejecting it at parse time would also turn a crafted + * link into a broken login page rather than an ordinary one. + */ +const loginSearchSchema = z.object({ + returnTo: z.string().optional(), +}) + +/** + * The page's own title, translated once, in the request's language. + * + * The cast is what makes `createTranslator` usable at all here. Its key type is + * derived from the *inferred* type of `messages`, and `AbstractIntlMessages` is + * a bare index signature (`{ [id: string]: AbstractIntlMessages | string }`) - + * so `MessageKeys` cannot tell a leaf from a branch and collapses to `never`, + * making every key a type error. Naming the one key this route reads is both the + * smallest fix and a true statement: if `core.global.login` is ever renamed, + * this stops compiling instead of rendering a raw message key. + * + * (`discover.tsx` gets away with `namespace: 'core.search'` uncast by accident - + * `search` happens to be a member of `String.prototype`, which perturbs the same + * inference into producing usable keys. Not a pattern to copy.) + */ +const translateTitle = (locale: string, messages: AbstractIntlMessages) => + createTranslator({ + locale, + messages: messages as { core: { global: { login: string } } }, + namespace: 'core.global', + })('login') + +export const Route = createFileRoute('/login')({ + validateSearch: loginSearchSchema, + /** + * Guest-only, decided before anything renders. + * + * A signed-in visitor never sees the form - not for a frame - because the + * decision happens in `beforeLoad` rather than in the component. + * + * ## Where they are sent, and how + * + * `?returnTo=` names wherever they were heading, and during the migration most + * of those places are still the Next.js app's. So the destination goes through + * the same rule `MigrationLink` applies, and produces one of two redirects: + * + * owned redirect({ to, search, hash }) client-side, in-app + * not owned redirect({ href, reloadDocument }) full document, legacy app + * + * Both work in both environments, which is the reason it is expressed as + * redirect *options* rather than as a navigation: on the server the router + * turns either into an HTTP redirect - to a path, or to the legacy public URL - + * and in the browser into a client navigation or a document load. Nothing here + * touches `window`, which a `beforeLoad` running during SSR does not have. + * + * **`to` rather than `href` for the owned branch.** A redirect carrying `href` + * is used verbatim by `Router.resolveRedirect` - it never reaches + * `buildLocation`, so it would skip the locale rewrite and drop a Polish + * visitor on the English page. The legacy branch wants exactly that verbatim + * behaviour, and gets the locale from `buildLegacyHref` instead. + * + * ## A failed session read is not a guest + * + * `ensureAuthState` rejects when the session could not be read at all, and + * that rejection propagates: only a session the API actually answered can send + * anybody anywhere. It reads the one canonical entry, so a guard that runs on + * hover (`defaultPreload: 'intent'`) shares its request with the one the + * navigation itself makes, and cannot create or end a session. + */ + beforeLoad: async ({ context, search }) => { + const auth = await ensureAuthState(context.queryClient) + + if (!canAccessGuestRoute(auth)) { + const href = postAuthDestination(search.returnTo) + + // TanStack Router's own control-flow signal - see the note in + // `routes/_authenticated.tsx`. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect( + migrationNavigateOptions( + resolveMigrationDestination({ + href, + isOwned: context.ownsPath(href), + locale: context.locale, + }), + ), + ) + } + }, + /** + * The two reads this page needs, in parallel and before it renders. + * + * Neither is repeated by the component: the messages are read back by + * `RouteMessages` through the identical `intlQueryOptions`, and the + * configuration by `useSuspenseQuery` through the identical + * `middlewareConfigQueryOptions`. A mismatch on either would show up as a + * render that starts empty and fills in a round trip later. + * + * The session is *not* fetched here. `beforeLoad` has already put it in the + * same cache entry every guard reads, and asking again would be a second + * request for an answer this route already has. + */ + loader: async ({ context }) => { + const [intl] = await Promise.all([ + context.queryClient.ensureQueryData( + intlQueryOptions({ + locale: context.locale, + namespaces: LOGIN_NAMESPACES, + }), + ), + context.queryClient.ensureQueryData(middlewareConfigQueryOptions()), + ]) + + return { title: translateTitle(context.locale, intl.messages) } + }, + /** + * The tab title, in the language the request resolved to - the same string the + * `

` renders, because the loader translated it once. + * + * **`head` must be written after `loader`**: `loaderData`'s type is inferred + * from `loader` in the same object literal, and TypeScript reads a literal's + * members in order. + * + * `formatPageTitle` applies the same `" - "` rule Next.js applies + * through `title.template`, so both frameworks produce the same title. + */ + head: ({ loaderData }) => ({ + meta: loaderData + ? [ + { + title: formatPageTitle( + vitNodeShellConfig.metadata, + loaderData.title, + ), + }, + ] + : [], + }), + component: LoginRoute, +}) + +function LoginRoute() { + const { returnTo } = Route.useSearch() + const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions()) + const signIn = useSignInAction(() => postAuthDestination(returnTo)) + + return ( + +
+ + } + LinkComponent={MigrationLink} + sso={ + + } + /> +
+
+ ) +} diff --git a/apps/web/src/routes/login_.sso.$providerId.tsx b/apps/web/src/routes/login_.sso.$providerId.tsx new file mode 100644 index 000000000..fd752a22a --- /dev/null +++ b/apps/web/src/routes/login_.sso.$providerId.tsx @@ -0,0 +1,192 @@ +import { useSuspenseQuery } from '@tanstack/react-query' +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { Button, buttonVariants } from '@vitnode/core/components/ui/button' +import { cn } from '@vitnode/core/lib/utils' +import { SSOCallbackContent } from '@vitnode/core/views/auth/sso/callback/sso-callback-content' +import { useSSOCallback } from '@vitnode/core/views/auth/sso/callback/use-sso-callback' +import { ArrowLeft, HomeIcon } from 'lucide-react' +import { useTranslations } from 'use-intl' +import { z } from 'zod' + +import { MigrationLink } from '#/components/migration-link' +import { RouteMessages } from '#/components/route-messages' +import { useCompleteSsoAction } from '#/lib/auth/actions' +import { parseSsoCallback } from '#/lib/auth/contract' +import { + parseInternalDestination, + postAuthDestination, +} from '#/lib/auth/redirects' +import { intlQueryOptions } from '#/lib/i18n/query' +import { + middlewareConfigQueryOptions, + ssoProvidersOf, +} from '#/lib/middleware-config' + +/** + * Where an SSO provider sends the visitor back to. + * + * `/login/sso/google` and `/pl/login/sso/google` are one route, and the URL + * shape is not this app's to choose: the API registers it with every provider as + * `${NEXT_PUBLIC_WEB_URL}login/sso/` (`api/models/sso.ts`), so whichever app + * that origin serves has to answer it. This is the TanStack Start half of that, + * matching the Next.js route at + * `packages/vitnode/src/routes/main/login/sso/[providerId]/page.tsx` exactly. + * + * ## Why it is a sibling of `/login` and not a child + * + * The file is `login_.sso.$providerId.tsx` - the trailing underscore opts out of + * nesting - and that spelling is load bearing twice over. + * + * 1. **The guest guard must not run here.** By the time a provider redirects + * back, the API has already minted its `--state-sso` cookie and the visitor + * may well have been signed in by a parallel tab. Sitting under `/login`'s + * guard, a signed-in visitor arriving with a valid `code` would be bounced + * away before the exchange ran, abandoning a half-finished OAuth round trip. + * An unfinished flow is finished here, whoever is asking. + * 2. **`/login` must stay an exact match.** A `/login` route with children is a + * route that matches `/login/reset-password` too, and `isTanStackOwnedPath` + * would then hand that legacy URL to this router as a client-side navigation + * to a page it cannot render. Two leaves, no shared parent. + * + * The exchange itself is unchanged and stays on the server: the API verifies + * `state` against the cookie it minted, deletes it, trades the `code` with the + * provider and mints the session. Nothing here re-implements or re-checks any of + * that. + */ + +const CALLBACK_NAMESPACES = ['core.global', 'core.auth.sso'] as const + +/** + * What a provider may put in the callback URL. + * + * Everything optional and nothing constrained, because which half arrives is the + * provider's decision - `code` and `state` when the visitor approved, `error` + * when they did not - and a schema that demanded either would turn a legitimate + * denial into a router error. The values are judged by `parseSsoCallback`, which + * bounds their length, classifies the error rather than carrying it through, and + * is where the whole rule lives. + */ +const callbackSearchSchema = z.object({ + code: z.string().optional(), + error: z.string().optional(), + state: z.string().optional(), +}) + +export const Route = createFileRoute('/login_/sso/$providerId')({ + validateSearch: callbackSearchSchema, + /** + * The provider names, and the strings the screens render. + * + * The provider list is what turns `google` in the URL into "Google" on the + * conflict screen. It is the same cache entry the login page warmed, so + * arriving here from a client-side navigation costs nothing. + * + * No session read and no guard: see the note above. + */ + loader: async ({ context }) => { + await Promise.all([ + context.queryClient.ensureQueryData( + intlQueryOptions({ + locale: context.locale, + namespaces: CALLBACK_NAMESPACES, + }), + ), + context.queryClient.ensureQueryData(middlewareConfigQueryOptions()), + ]) + }, + component: SsoCallbackRoute, +}) + +/** + * "Go back" and "go home", for the two screens that end in a dead end. + * + * The TanStack half of what `ErrorViewActions` renders in Next.js: the same two + * buttons and the same two strings, with this framework's navigation behind + * them. `errorActions` is a slot on the shared screen precisely because this is + * the part that cannot be shared - `router.history.back()` here, + * `next-intl`'s `useRouter().back()` there. + * + * Declared at module scope so it is the same component type on every render. + */ +const CallbackErrorActions = () => { + const router = useRouter() + const t = useTranslations('core.global') + + return ( + <> + + + + + {t('back_home')} + + + ) +} + +function SsoCallbackRoute() { + const { providerId } = Route.useParams() + const search = Route.useSearch() + const router = useRouter() + const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions()) + + /** + * The callback URL, judged before any of it is sent on: the provider id has to + * be a slug, `code` and `state` have to be present and bounded, and an `error` + * is classified rather than carried. A malformed callback never reaches the + * API. + */ + const parsed = parseSsoCallback({ providerId, query: search }) + const completeSso = useCompleteSsoAction(parsed.ok ? parsed.params : null) + + /** + * The exchange, run once, by the shared hook both frameworks use. + * + * `oauthError` is the raw `error` parameter, which is what the hook's own rule + * is written against: `access_denied` disables the query outright - there is + * nothing to exchange when the visitor said no - and anything else lets it run + * and fail, which is the screen a provider error should produce anyway. The + * exchange itself refuses to call the API unless `parseSsoCallback` produced + * parameters, so neither a malformed callback nor a provider error costs a + * request. + */ + const state = useSSOCallback({ + code: parsed.ok ? parsed.params.code : '', + oauthError: search.error, + onCallback: completeSso, + // The front page, through the same rule the login form uses. There is no + // `returnTo` to honour here and there must not be: this URL is built by the + // provider from what the API registered with it, so anything in its query + // came back from another origin. Which is also what the Next.js flow does - + // `replace("/")`. + onSignedIn: () => { + void router.navigate( + parseInternalDestination(postAuthDestination(undefined)), + ) + }, + providerId, + }) + + return ( + +
+ } + LinkComponent={MigrationLink} + providerId={providerId} + providers={ssoProvidersOf(config)} + state={state} + /> +
+
+ ) +} diff --git a/apps/web/src/server/auth.server.ts b/apps/web/src/server/auth.server.ts new file mode 100644 index 000000000..9d77e9385 --- /dev/null +++ b/apps/web/src/server/auth.server.ts @@ -0,0 +1,209 @@ +import '@tanstack/react-start/server-only' +import type { usersModule } from '@vitnode/core/api/modules/users/users.module' + +import { clientModule } from '@vitnode/core/lib/fetcher-client' + +import type { + CompleteSsoResult, + SignInInput, + SignInResult, + SignOutInput, + SignOutResult, + SsoCallbackInput, + SsoStartInput, + SsoStartResult, +} from '#/lib/auth/contract' + +import { + completeSsoResultFromStatus, + shouldSaveApiCookies, + signInResultFromStatus, + signOutResultFromStatus, + ssoStartResultFromStatus, +} from '#/lib/auth/contract' +import { fetcherServer, saveApiCookies } from '#/server/fetcher.server' + +/** + * The auth mutations against the Hono users API - the half that can only run on + * a server. + * + * server function -> here -> fetcherServer -> Hono users API + * | + * browser <- saveApiCookies <- Set-Cookie + * + * Split out of `#/lib/auth/mutations` for the same reason + * `discover-feed.server.ts` is split out of `lib/search/discover-feed.ts`: that + * module is imported by the browser bundle, and this one imports the request + * scope (`getRequestHeaders`, `setCookie`) and the `server-only` marker above. + * Reached only from inside a `createServerFn` handler, which is what keeps it - + * and the marker - out of the client build. + * + * The API is unchanged and unrelaxed: it still hashes the password, mints the + * session, verifies the OAuth `state` against its own cookie and decides every + * status. Everything here is transport - forward the request state, copy the + * cookies back, and turn a status into one of the finite results in + * `#/lib/auth/contract`. + */ + +/** + * The users module by type only, so nothing the API needs at runtime - Hono, + * Drizzle, the plugin tree - is pulled in by a value import. `clientModule` + * keeps the route paths, methods and response schemas fully typed while + * carrying just the `pluginId` the fetcher reads. + */ +const users = clientModule('@vitnode/core') + +/** + * The API's reply, or `null` when the call never produced one. + * + * `rawApiFetch` *throws* on a 500 rather than returning it, with the failing URL + * and the server's error text in the message, and a fetch to a server that is + * not listening throws too. Both have to be caught: an error escaping a server + * function is serialized back to the browser, which would put exactly that text + * in front of a visitor. It is logged where a server log is the right place for + * it, and `null` becomes the same `server_error` any other unexpected status + * maps to. + */ +const callUsersApi = async ( + call: () => Promise, +): Promise => { + try { + return await call() + } catch (error) { + // eslint-disable-next-line no-console + console.error('[auth] users API call failed', error) + + return null + } +} + +/** + * Copies the session, device and SSO-state cookies the API just minted onto this + * app's response. + * + * 2xx only - see `shouldSaveApiCookies`, which is the rule Next's + * `allowSaveCookies` applies and therefore the one the legacy flow was built on. + * Guarded rather than unconditional because `saveApiCookies` writes every cookie + * a response carries, so it is only ever handed a reply this app decided to + * trust. + */ +const saveCookiesFrom = (response: Response): void => { + if (shouldSaveApiCookies(response.status)) saveApiCookies(response) +} + +/** + * Signs a visitor in with an email and a password. + * + * `201` with the session cookie attached, `403` for an unknown address or a + * wrong password. The cookie is the entire point of the round trip, so it is + * copied before anything looks at the status. + */ +export const signInOnApi = async (data: SignInInput): Promise => { + const response = await callUsersApi(async () => + fetcherServer(users, { + args: { body: data }, + method: 'post', + module: 'users', + path: '/sign_in', + }), + ) + + if (!response) return { ok: false, reason: 'server_error' } + + saveCookiesFrom(response) + + return signInResultFromStatus(response.status) +} + +/** + * Ends the current session. + * + * The request has to carry the visitor's cookies - that is how the API knows + * which session row to delete - and the reply's `Set-Cookie` has to come back, + * because that *is* the deletion. Hono spells it `name=; Max-Age=0`, which + * `parseSetCookies` preserves as `maxAge: 0`; dropping it would leave an empty + * cookie in the browser until it closed. + */ +export const signOutOnApi = async ( + data: SignOutInput, +): Promise => { + const response = await callUsersApi(async () => + fetcherServer(users, { + args: { body: { isAdmin: data.isAdmin ?? false } }, + method: 'delete', + module: 'users', + path: '/sign_out', + }), + ) + + if (!response) return { ok: false, reason: 'server_error' } + + saveCookiesFrom(response) + + return signOutResultFromStatus(response.status) +} + +/** + * Starts an SSO sign-in: asks the API for the provider's authorization URL. + * + * The reply carries a cookie as well as a URL - the API mints the OAuth `state` + * and stores its hash in a short-lived `--state-sso` cookie - so this is a + * mutation with a `Set-Cookie` like the others, and losing that cookie means the + * callback fails its state check. Which is the whole reason it goes through a + * server function rather than a browser fetch. + */ +export const startSsoOnApi = async ( + data: SsoStartInput, +): Promise => { + const response = await callUsersApi(async () => + fetcherServer(users, { + args: { params: { providerId: data.providerId } }, + method: 'post', + module: 'users/sso', + path: '/{providerId}', + }), + ) + + if (!response) return { ok: false, reason: 'server_error' } + + saveCookiesFrom(response) + + if (response.status !== 200) { + return ssoStartResultFromStatus(response.status, undefined) + } + + const { url } = await response.json() + + return ssoStartResultFromStatus(response.status, url) +} + +/** + * Completes an SSO sign-in with what the provider sent the visitor back with. + * + * The API does all of the security-relevant work and keeps doing it: it verifies + * `state` against the `--state-sso` cookie this request forwards, deletes that + * cookie, exchanges the `code` with the provider and mints the session. This + * layer validates the shape of the three values, forwards them, and copies the + * session cookie back. + */ +export const completeSsoOnApi = async ( + data: SsoCallbackInput, +): Promise => { + const response = await callUsersApi(async () => + fetcherServer(users, { + args: { + params: { providerId: data.providerId }, + query: { code: data.code, state: data.state }, + }, + method: 'get', + module: 'users/sso', + path: '/{providerId}/callback', + }), + ) + + if (!response) return { ok: false, reason: 'server_error' } + + saveCookiesFrom(response) + + return completeSsoResultFromStatus(response.status) +} diff --git a/apps/web/src/server/middleware-config.server.ts b/apps/web/src/server/middleware-config.server.ts new file mode 100644 index 000000000..f52cb5175 --- /dev/null +++ b/apps/web/src/server/middleware-config.server.ts @@ -0,0 +1,49 @@ +import '@tanstack/react-start/server-only' +import type { middlewareModule } from '@vitnode/core/api/modules/middleware/middleware.module' + +import { clientModule } from '@vitnode/core/lib/fetcher-client' + +import type { MiddlewareConfig } from '#/lib/middleware-config' + +import { ANONYMOUS_MIDDLEWARE_CONFIG } from '#/lib/middleware-config' +import { fetcherServer } from '#/server/fetcher.server' + +/** + * The deployment's auth configuration, read during SSR - the TanStack Start + * counterpart of `@vitnode/core`'s `getMiddlewareApi()`. + * + * `fetcherServer` rather than a bare fetch, for the same reason the Discover + * feed uses it: the API origin comes from the request being rendered, and the + * visitor's `user-agent` and `x-forwarded-for` go with the call so the rate + * limiter buckets it correctly. The response itself is the same for everyone. + * + * Reached only through the isomorphic transport in `#/lib/middleware-config`, + * which is what keeps this module - and its `server-only` marker - out of the + * browser bundle. + */ +const middleware = clientModule('@vitnode/core') + +export const fetchMiddlewareConfigOnServer = + async (): Promise => { + try { + const response = await fetcherServer(middleware, { + method: 'get', + module: 'middleware', + path: '/', + }) + + if (response.status !== 200) return ANONYMOUS_MIDDLEWARE_CONFIG + + return await response.json() + } catch (error) { + // `rawApiFetch` throws on a 500 with the failing URL and the server's error + // text in the message, and an unreachable API throws too. Neither belongs in + // front of a visitor, and neither should blank the login form: without this + // configuration the page still renders, minus the provider buttons and the + // reset-password link. + // eslint-disable-next-line no-console + console.error('[auth] middleware configuration unavailable', error) + + return ANONYMOUS_MIDDLEWARE_CONFIG + } + } diff --git a/apps/web/src/tests/auth-contract.test.ts b/apps/web/src/tests/auth-contract.test.ts new file mode 100644 index 000000000..7e4a1a063 --- /dev/null +++ b/apps/web/src/tests/auth-contract.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from 'vitest' + +import { + completeSsoResultFromStatus, + isProviderRedirectUrl, + isUsableSessionStatus, + parseSsoCallback, + providerIdSchema, + shouldSaveApiCookies, + signInInputSchema, + signInResultFromStatus, + signOutResultFromStatus, + ssoCallbackInputSchema, + ssoStartResultFromStatus, +} from '#/lib/auth/contract' + +/** + * The auth transport's decisions, without the transport. + * + * Every status the API can answer these four calls with, and every shape a + * provider can send a visitor back with, mapped to the finite result a component + * is allowed to see. No Hono, no fetch, no server function - those are covered + * by typecheck and the build. + */ +describe('sign-in results', () => { + it('reads 201 as signed in', () => { + expect(signInResultFromStatus(201)).toEqual({ ok: true }) + }) + + it('reads 403 as a rejected credential rather than a failure', () => { + expect(signInResultFromStatus(403)).toEqual({ + ok: false, + reason: 'access_denied', + }) + }) + + it.each([200, 400, 404, 409, 429, 500, 503])( + 'collapses %i into one server_error', + (status) => { + expect(signInResultFromStatus(status)).toEqual({ + ok: false, + reason: 'server_error', + }) + }, + ) +}) + +describe('sign-out results', () => { + it('reads 200 as signed out', () => { + expect(signOutResultFromStatus(200)).toEqual({ ok: true }) + }) + + it.each([204, 403, 429, 500])('reads %i as a failure', (status) => { + expect(signOutResultFromStatus(status)).toEqual({ + ok: false, + reason: 'server_error', + }) + }) +}) + +describe('SSO start results', () => { + it('returns the provider URL on 200', () => { + expect( + ssoStartResultFromStatus( + 200, + 'https://accounts.google.com/o/oauth2/v2/auth?state=a', + ), + ).toEqual({ + ok: true, + url: 'https://accounts.google.com/o/oauth2/v2/auth?state=a', + }) + }) + + it('reads 404 as a provider this install does not have', () => { + expect(ssoStartResultFromStatus(404, undefined)).toEqual({ + ok: false, + reason: 'unknown_provider', + }) + }) + + it.each([429, 500])('reads %i as a failure', (status) => { + expect(ssoStartResultFromStatus(status, undefined)).toEqual({ + ok: false, + reason: 'server_error', + }) + }) + + it.each([ + 'javascript:alert(1)', + 'data:text/html,x', + '/login', + '', + undefined, + null, + 42, + ])('refuses to hand back %j as a navigation target', (url) => { + // The caller puts a browser at this value, so a 200 carrying something that + // is not an http(s) URL is a broken adapter, not a redirect. + expect(ssoStartResultFromStatus(200, url)).toEqual({ + ok: false, + reason: 'server_error', + }) + }) + + it.each([ + 'https://discord.com/oauth2/authorize?state=a', + 'http://localhost:3000/oauth?state=a', + ])('accepts %s', (url) => { + expect(isProviderRedirectUrl(url)).toBe(true) + }) +}) + +describe('SSO callback results', () => { + it('reads 200 as signed in', () => { + expect(completeSsoResultFromStatus(200)).toEqual({ ok: true }) + }) + + it.each([ + [400, 'invalid_state'], + [404, 'unknown_provider'], + [409, 'email_exists'], + [429, 'server_error'], + [500, 'server_error'], + ] as const)('reads %i as %s', (status, reason) => { + expect(completeSsoResultFromStatus(status)).toEqual({ ok: false, reason }) + }) +}) + +describe('cookie propagation rule', () => { + it.each([200, 201, 204, 299])('copies the cookies a %i carries', (status) => { + expect(shouldSaveApiCookies(status)).toBe(true) + }) + + it.each([301, 400, 403, 409, 429, 500])( + 'writes nothing to the browser for a %i', + (status) => { + expect(shouldSaveApiCookies(status)).toBe(false) + }, + ) +}) + +describe('provider id validation', () => { + it.each(['google', 'discord', 'facebook', 'my-idp', 'idp_2'])( + 'accepts %s', + (providerId) => { + expect(providerIdSchema.safeParse(providerId).success).toBe(true) + }, + ) + + it.each([ + // The fetcher interpolates this into the request path without encoding it. + '../../session', + 'google/../../sign_in', + 'google%2F..', + 'google?x=1', + 'google.com', + '-google', + '', + 'a'.repeat(65), + ])('rejects %j', (providerId) => { + expect(providerIdSchema.safeParse(providerId).success).toBe(false) + }) +}) + +describe('sign-in input validation', () => { + it('lowercases the address the way the API does before looking it up', () => { + const parsed = signInInputSchema.parse({ + email: 'Test@Test.com', + password: 'Test123!', + }) + + expect(parsed).toEqual({ email: 'test@test.com', password: 'Test123!' }) + }) + + it.each([ + { email: 'not-an-email', password: 'Test123!' }, + { email: 'test@test.com', password: '' }, + { email: 'test@test.com' }, + { password: 'Test123!' }, + { email: 'test@test.com', isAdmin: 'yes', password: 'Test123!' }, + ])('rejects %j', (input) => { + expect(signInInputSchema.safeParse(input).success).toBe(false) + }) + + it('drops anything the API was not asked for', () => { + expect( + signInInputSchema.parse({ + email: 'test@test.com', + isAdmin: true, + password: 'Test123!', + returnTo: '/settings', + }), + ).toEqual({ email: 'test@test.com', isAdmin: true, password: 'Test123!' }) + }) +}) + +describe('SSO callback input normalisation', () => { + const providerId = 'google' + const params = { code: 'oauth-code', providerId, state: 'abcdef0123456789' } + + it('reads an approved callback', () => { + expect( + parseSsoCallback({ + providerId, + query: { code: params.code, state: params.state }, + }), + ).toEqual({ ok: true, params }) + }) + + it('reads URLSearchParams as well as a search object', () => { + expect( + parseSsoCallback({ + providerId, + query: new URLSearchParams({ code: params.code, state: params.state }), + }), + ).toEqual({ ok: true, params }) + }) + + it('reads the visitor declining at the provider', () => { + expect( + parseSsoCallback({ providerId, query: { error: 'access_denied' } }), + ).toEqual({ ok: false, reason: 'access_denied' }) + }) + + it('classifies any other provider error without carrying its text', () => { + const parsed = parseSsoCallback({ + providerId, + query: { + error: 'server_error', + error_description: '', + }, + }) + + expect(parsed).toEqual({ ok: false, reason: 'provider_error' }) + }) + + it('prefers the error over a code sent alongside it', () => { + expect( + parseSsoCallback({ + providerId, + query: { + code: params.code, + error: 'access_denied', + state: params.state, + }, + }), + ).toEqual({ ok: false, reason: 'access_denied' }) + }) + + it.each([ + { state: 'abcdef0123456789' }, + { code: 'oauth-code' }, + { code: '', state: 'abcdef0123456789' }, + { code: 'oauth-code', state: '' }, + { code: 'a'.repeat(2049), state: 'abcdef0123456789' }, + ])('rejects the unusable callback %j', (query) => { + expect(parseSsoCallback({ providerId, query })).toEqual({ + ok: false, + reason: 'invalid_callback', + }) + }) + + it.each([undefined, '', '../../session', 'google/../..'])( + 'rejects the callback of provider %j', + (badProviderId) => { + expect( + parseSsoCallback({ + providerId: badProviderId, + query: { code: params.code, state: params.state }, + }), + ).toEqual({ ok: false, reason: 'invalid_callback' }) + }, + ) + + it.each([undefined, null, 'not-a-query', 42])( + 'rejects a query of %j', + (query) => { + expect(parseSsoCallback({ providerId, query })).toEqual({ + ok: false, + reason: 'invalid_callback', + }) + }, + ) + + it('feeds its params straight to the callback validator', () => { + // The one contract between the normaliser and the server function: whatever + // `parseSsoCallback` calls valid is what `completeSso` accepts. + const parsed = parseSsoCallback({ + providerId, + query: { code: params.code, state: params.state }, + }) + + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + + expect(ssoCallbackInputSchema.safeParse(parsed.params).success).toBe(true) + }) +}) + +/** + * The distinction the auth stack got wrong for a whole stage. + * + * `200 + { user: null }` is a visitor who is genuinely nobody. Every other + * answer means the session could not be *evaluated*, and reading that as + * "anonymous" is what signed people out during a rate-limit spike. There is no + * third status the session route declares, so `200` is the whole rule. + */ +describe('reading a session response status', () => { + it('treats 200 as a session that can be read', () => { + expect(isUsableSessionStatus(200)).toBe(true) + }) + + it.each([204, 400, 401, 403, 404, 429, 500, 502, 503])( + 'treats %i as a failed read rather than as an anonymous visitor', + (status) => { + expect(isUsableSessionStatus(status)).toBe(false) + }, + ) +}) diff --git a/apps/web/src/tests/auth-redirects.test.ts b/apps/web/src/tests/auth-redirects.test.ts new file mode 100644 index 000000000..54a7d39e6 --- /dev/null +++ b/apps/web/src/tests/auth-redirects.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' + +import { + LOGIN_PATH, + parseInternalDestination, + postAuthDestination, + returnToFor, +} from '#/lib/auth/redirects' + +/** + * Where the auth flow sends people. + * + * Two directions, both pure string transforms, so the whole policy is a table: + * what a blocked visitor carries to the login page, and where a signed-in one is + * sent from it. The safety half - which targets are acceptable at all - is + * `auth-return-to.test.ts`; these are the decisions layered on top of it. + */ + +describe('postAuthDestination', () => { + it.each([ + ['/discover', '/discover'], + ['/settings/security?tab=devices', '/settings/security?tab=devices'], + ['/pl/discover', '/pl/discover'], + ['/discover#results', '/discover#results'], + ])('keeps the application path %s', (input, expected) => { + expect(postAuthDestination(input)).toBe(expected) + }) + + it.each([ + ['no target at all', undefined], + ['a target that is not a string', 42], + ['an absolute URL', 'https://evil.example.com/'], + ['a protocol-relative URL', '//evil.example.com/'], + ['a script URL', 'javascript:alert(1)'], + ['a data URL', 'data:text/html,'], + ['a backslash-disguised host', '/\\evil.example.com'], + ['a newline-split scheme', '/\njavascript:alert(1)'], + ])('falls back to the front page for %s', (_why, input) => { + expect(postAuthDestination(input)).toBe('/') + }) + + /** + * The loop guard. Without it, `/login?returnTo=/login` sends a signed-in + * visitor to the login page, whose guard sends them to the login page. + */ + it.each([ + LOGIN_PATH, + `${LOGIN_PATH}?returnTo=/discover`, + `${LOGIN_PATH}/reset-password`, + `${LOGIN_PATH}/sso/google?code=abc`, + ])('refuses to send a signed-in visitor back to %s', (input) => { + expect(postAuthDestination(input)).toBe('/') + }) + + it('does not treat a path that merely starts with the same letters as the login page', () => { + expect(postAuthDestination('/logins')).toBe('/logins') + expect(postAuthDestination('/login-help')).toBe('/login-help') + }) +}) + +describe('returnToFor', () => { + it('carries the internal path, its query and its hash', () => { + expect( + returnToFor({ + hash: 'devices', + pathname: '/settings/security', + searchStr: '?tab=devices', + }), + ).toBe('/settings/security?tab=devices#devices') + }) + + it('accepts a hash that already carries its own #', () => { + expect(returnToFor({ hash: '#devices', pathname: '/settings' })).toBe( + '/settings#devices', + ) + }) + + /** + * The locale is deliberately absent. `location.pathname` is what the route + * tree matched - the rewrite has already stripped `/pl` - so the value that + * round-trips through the login URL carries no language and the prefix is + * written back exactly once, by the rewrite, when the router builds the way + * home. + */ + it('carries no locale prefix, because the internal path has none', () => { + expect(returnToFor({ pathname: '/settings' })).toBe('/settings') + expect(returnToFor({ pathname: '/settings' })).not.toContain('/pl') + }) + + it.each([ + ['the front page, which is the default anyway', '/'], + ['the login page itself', LOGIN_PATH], + ['a page under the login page', `${LOGIN_PATH}/sso/google`], + ])('attaches nothing for %s', (_why, pathname) => { + expect(returnToFor({ pathname })).toBeUndefined() + }) +}) + +describe('parseInternalDestination', () => { + /** + * Split rather than handed over as one `href`, because a redirect carrying + * `href` is used verbatim by `Router.resolveRedirect` - it never reaches + * `buildLocation`, so it never runs the locale rewrite, and a Polish visitor + * would land on the English page. + */ + it('splits a path into the fields a router navigation takes', () => { + expect( + parseInternalDestination('/settings/security?tab=devices#top'), + ).toEqual({ + hash: 'top', + search: { tab: 'devices' }, + to: '/settings/security', + }) + }) + + it('omits the parts that are not there, rather than sending empty ones', () => { + expect(parseInternalDestination('/discover')).toEqual({ to: '/discover' }) + }) + + it('leaves a locale prefix in the path for the rewrite to normalise', () => { + expect(parseInternalDestination('/pl/discover').to).toBe('/pl/discover') + }) +}) diff --git a/apps/web/src/tests/auth-return-to.test.ts b/apps/web/src/tests/auth-return-to.test.ts new file mode 100644 index 000000000..6c85947fb --- /dev/null +++ b/apps/web/src/tests/auth-return-to.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_RETURN_TO, + isSafeReturnTo, + sanitizeReturnTo, +} from '#/lib/auth/return-to' + +/** + * The post-login redirect target, which is the one auth input any visitor can + * put anything into. Everything here is a pure string transform, so the whole + * rule can be stated as a table rather than exercised through a browser. + */ +describe('sanitizeReturnTo keeps application-relative paths', () => { + it.each([ + '/', + '/discover', + '/pl/discover', + '/settings', + '/settings/security?tab=devices', + '/settings#password', + ])('keeps %s', (target) => { + expect(sanitizeReturnTo(target)).toBe(target) + expect(isSafeReturnTo(target)).toBe(true) + }) + + it('normalises a path rather than echoing it back unparsed', () => { + expect(sanitizeReturnTo('/settings/../discover')).toBe('/discover') + }) +}) + +describe('sanitizeReturnTo rejects anything that can leave this origin', () => { + it.each([ + // An absolute URL - the open redirect that turns a login page into a + // phishing hop. + 'https://evil.example.com', + 'http://evil.example.com/discover', + // Protocol-relative, and the backslash spelling of it the URL parser reads + // the same way. + '//evil.example.com', + '/\\evil.example.com', + '/\\/evil.example.com', + // Scheme-carrying values, including the ones browsers reach by stripping + // whitespace out of the string first. + 'javascript:alert(1)', + 'java\nscript:alert(1)', + ' javascript:alert(1)', + 'data:text/html,', + // Not a path at all. + 'discover', + '', + '?tab=devices', + '#password', + ])('rejects %j', (target) => { + expect(sanitizeReturnTo(target)).toBe(DEFAULT_RETURN_TO) + expect(isSafeReturnTo(target)).toBe(false) + }) + + it.each([undefined, null, 42, {}, ['/discover']])( + 'rejects the non-string %j', + (target) => { + expect(sanitizeReturnTo(target)).toBe(DEFAULT_RETURN_TO) + expect(isSafeReturnTo(target)).toBe(false) + }, + ) +}) + +describe('sanitizeReturnTo falls back predictably', () => { + it('uses the caller fallback when there is no target', () => { + expect(sanitizeReturnTo(undefined, { fallback: '/discover' })).toBe( + '/discover', + ) + }) + + it('holds the fallback to the same rule as the target', () => { + // A fallback is code rather than input, but trusting it for that reason is + // how one gets written as a full URL and never noticed. + expect( + sanitizeReturnTo('https://evil.example.com', { + fallback: 'https://also-evil.example.com', + }), + ).toBe(DEFAULT_RETURN_TO) + }) +}) diff --git a/apps/web/src/tests/auth-screens.test.ts b/apps/web/src/tests/auth-screens.test.ts new file mode 100644 index 000000000..ddb17f399 --- /dev/null +++ b/apps/web/src/tests/auth-screens.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionApi } from '#/lib/session' + +import { + anonymousSession, + signInFormResult, + ssoCallbackResult, + ssoStartFeedback, +} from '#/lib/auth/screens' + +/** + * The auth contract translated into the vocabulary `@vitnode/core`'s shared auth + * screens speak. Total functions over finite unions, so every outcome the API + * can produce is checked here rather than in a browser. + */ + +describe('signInFormResult', () => { + it('says nothing on success, which is how the shared form knows to stand down', () => { + expect(signInFormResult({ ok: true })).toBeUndefined() + }) + + it('renders a denial in the form', () => { + expect(signInFormResult({ ok: false, reason: 'access_denied' })).toEqual({ + message: 'access_denied', + }) + }) + + it('renders anything else as the internal-error toast', () => { + expect(signInFormResult({ ok: false, reason: 'server_error' })).toEqual({ + message: 'Internal Server Error', + }) + }) +}) + +describe('ssoStartFeedback', () => { + it('says nothing on success - the caller has a browser to send to the provider', () => { + expect( + ssoStartFeedback({ + ok: true, + url: 'https://accounts.google.com/o/oauth2', + }), + ).toBeUndefined() + }) + + it.each(['server_error', 'unknown_provider'] as const)( + 'asks the button row for a toast on %s', + (reason) => { + expect(ssoStartFeedback({ ok: false, reason })).toEqual({ + message: reason, + }) + }, + ) +}) + +describe('ssoCallbackResult', () => { + it('reports no failure on success', () => { + expect(ssoCallbackResult({ ok: true })).toEqual({}) + }) + + it('keeps the one failure a visitor can act on', () => { + expect(ssoCallbackResult({ ok: false, reason: 'email_exists' })).toEqual({ + failure: 'email_exists', + }) + }) + + it.each(['invalid_state', 'server_error', 'unknown_provider'] as const)( + 'collapses %s, which a visitor cannot act on differently', + (reason) => { + expect(ssoCallbackResult({ ok: false, reason })).toEqual({ + failure: 'unknown', + }) + }, + ) +}) + +describe('anonymousSession', () => { + const session = { + ai: { models: ['anthropic:claude-sonnet-5'] }, + user: { email: 'test@test.com', id: 1, name: 'Test' }, + } as unknown as SessionApi + + it('removes the visitor', () => { + expect(anonymousSession(session).user).toBeNull() + }) + + /** + * Everything about the *installation* survives a sign-out. Building + * `{ ai: { models: [] }, user: null }` here instead would be a second, + * quietly diverging definition of the anonymous session. + */ + it('keeps everything that describes the installation rather than the visitor', () => { + expect(anonymousSession(session).ai).toEqual(session.ai) + }) + + it('does not mutate the session it was given', () => { + anonymousSession(session) + + expect(session.user).not.toBeNull() + }) +}) diff --git a/apps/web/src/tests/auth-state.test.ts b/apps/web/src/tests/auth-state.test.ts new file mode 100644 index 000000000..c2f175cf3 --- /dev/null +++ b/apps/web/src/tests/auth-state.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest' + +import type { AuthUser } from '#/lib/auth/shared' +import type { SessionApi } from '#/lib/session' + +import { i18n } from '#/i18n' +import { + authStateFromSession, + canAccessAdminRoute, + canAccessAuthenticatedRoute, + canAccessGuestRoute, + SESSION_QUERY_KEY, +} from '#/lib/auth/shared' + +/** + * The Stage 6 auth contract: + * + * session (API) -> authStateFromSession -> route context -> guards + * + * Only the pure half is exercised here, which is also the only half worth + * testing: the transport is one `createServerFn` around a `GET`, and the + * authorization that actually matters lives in Hono, on the server, behind the + * session cookie. What can silently go wrong on this side is the *derivation* - + * a guest read as signed in, an unimplemented role read as a permission, a query + * key that quietly varies per language - and all three are decided by the + * functions below. + * + * `SessionApi` is imported as a type only, so nothing here loads the server + * function or the fetcher it reaches for. + */ + +/** + * A visitor who is genuinely nobody: the API answered `200`, and there is no + * user in the answer. + * + * That is now the *only* thing `user: null` can mean. `getSession` used to + * synthesize this exact object for any non-200 - a 429, a 500, an unreachable + * API - which made an outage indistinguishable from a sign-out and bounced + * signed-in visitors to the login page. It rejects instead, so a failed read + * cannot reach `authStateFromSession` at all; `isUsableSessionStatus` in + * `auth-contract.test.ts` pins the rule that decides it. + */ +const anonymousSession: SessionApi = { ai: { models: [] }, user: null } + +/** A signed-in visitor, exactly as `users/session.route.ts` describes one. */ +const userFixture = (overrides: Partial = {}): AuthUser => ({ + avatarColor: '#101010', + birthday: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + email: 'test@test.com', + emailVerified: true, + id: 1, + isAdmin: false, + isModerator: false, + name: 'Test', + nameCode: 'test', + newsletter: false, + roleId: 1, + ...overrides, +}) + +const sessionFor = (user: AuthUser): SessionApi => ({ + ai: { models: [] }, + user, +}) + +describe('a session becomes an auth state', () => { + it('decides on the user and on nothing else', () => { + // The union has two members and no third for "we could not find out", which + // is why this is total: every session the API can answer with maps to one + // of them, and everything else is an error before it gets here. + expect(authStateFromSession(anonymousSession).isAuthenticated).toBe(false) + expect( + authStateFromSession(sessionFor(userFixture())).isAuthenticated, + ).toBe(true) + }) + + it('reads a null user as a guest', () => { + const auth = authStateFromSession(anonymousSession) + + expect(auth.isAuthenticated).toBe(false) + expect(auth.isAdmin).toBe(false) + expect(auth.user).toBeNull() + }) + + it('reads a user as authenticated, without copying them', () => { + const user = userFixture() + const session = sessionFor(user) + const auth = authStateFromSession(session) + + expect(auth.isAuthenticated).toBe(true) + // The same objects, not clones: a page reads the visitor and `ai.models` + // off this state, and a copy is a second answer that can drift. + expect(auth.user).toBe(user) + expect(auth.session).toBe(session) + }) + + it('reads an admin as an admin', () => { + const auth = authStateFromSession( + sessionFor(userFixture({ isAdmin: true })), + ) + + expect(auth.isAuthenticated).toBe(true) + expect(auth.isAdmin).toBe(true) + }) + + /** + * The API answers `isModerator: false` unconditionally - it is a `TODO`, not a + * role. So the auth state must not carry a moderator flag at all: one would + * read as authorization while being a constant, and would start granting + * access on its own the day the API begins computing it. + */ + it('does not promote a moderator to anything', () => { + const auth = authStateFromSession( + sessionFor(userFixture({ isModerator: true })), + ) + + expect(auth.isAdmin).toBe(false) + expect('isModerator' in auth).toBe(false) + }) + + /** + * `beforeLoad` also runs on preload, on hover, and again on the navigation + * itself. The derivation therefore has to be a function of its argument and + * nothing else - no clock, no counter, no cache of its own. + */ + it('answers the same session identically every time', () => { + const session = sessionFor(userFixture({ isAdmin: true })) + + expect(authStateFromSession(session)).toEqual(authStateFromSession(session)) + expect(authStateFromSession(anonymousSession)).toEqual( + authStateFromSession(anonymousSession), + ) + }) +}) + +describe('the access predicates', () => { + const guest = authStateFromSession(anonymousSession) + const member = authStateFromSession(sessionFor(userFixture())) + const admin = authStateFromSession(sessionFor(userFixture({ isAdmin: true }))) + + it('lets only a guest into a guest-only route', () => { + expect(canAccessGuestRoute(guest)).toBe(true) + expect(canAccessGuestRoute(member)).toBe(false) + expect(canAccessGuestRoute(admin)).toBe(false) + }) + + it('lets only a signed-in visitor into an authenticated route', () => { + expect(canAccessAuthenticatedRoute(guest)).toBe(false) + expect(canAccessAuthenticatedRoute(member)).toBe(true) + expect(canAccessAuthenticatedRoute(admin)).toBe(true) + }) + + it('lets only an admin into an admin route', () => { + expect(canAccessAdminRoute(guest)).toBe(false) + expect(canAccessAdminRoute(member)).toBe(false) + expect(canAccessAdminRoute(admin)).toBe(true) + }) + + it('never opens both a guest route and an authenticated one', () => { + for (const auth of [guest, member, admin]) { + expect(canAccessGuestRoute(auth)).toBe(!canAccessAuthenticatedRoute(auth)) + } + }) +}) + +describe('the session cache key', () => { + it('is one stable entry', () => { + expect(SESSION_QUERY_KEY).toEqual(['vitnode', 'session']) + }) + + /** + * The session is *who* the visitor is, which does not change because they read + * the page in Polish. A locale in the key would give one visitor two sessions + * invalidated separately, so signing out on `/pl` would leave `/` still + * rendering a signed-in header. + */ + it('carries no locale', () => { + const localeCodes: string[] = i18n.locales.map(({ code }) => code) + const key: readonly string[] = SESSION_QUERY_KEY + + expect(key.filter((part) => localeCodes.includes(part))).toEqual([]) + }) +}) diff --git a/apps/web/src/tests/isolation.test.ts b/apps/web/src/tests/isolation.test.ts index 0913d4050..2e1888f4e 100644 --- a/apps/web/src/tests/isolation.test.ts +++ b/apps/web/src/tests/isolation.test.ts @@ -350,7 +350,21 @@ describe('the whole graph this app imports stays Next-free', () => { /** Everything the app reaches, from every entry point it has. */ const ENTRIES = [ 'apps/web/src/components/language-switcher.tsx', + 'apps/web/src/components/migration-link.tsx', 'apps/web/src/components/route-messages.tsx', + // Stage 6. The auth surface reaches deepest into `@vitnode/core` of + // anything this app renders - the shared login card pulls in `AutoForm`, + // and with it the whole form and design-system stack. That graph was + // Next-only until `hooks/use-captcha.ts` stopped importing + // `@/lib/navigation`, so it is exactly the graph worth walking here. + 'apps/web/src/lib/auth/actions.ts', + 'apps/web/src/lib/auth/redirects.ts', + 'apps/web/src/lib/auth/screens.ts', + 'apps/web/src/lib/middleware-config.ts', + 'apps/web/src/routes/_authenticated.tsx', + 'apps/web/src/routes/_authenticated/account.tsx', + 'apps/web/src/routes/login.tsx', + 'apps/web/src/routes/login_.sso.$providerId.tsx', 'apps/web/src/lib/i18n/client.ts', 'apps/web/src/lib/i18n/query.ts', 'apps/web/src/lib/i18n/shared.ts', diff --git a/apps/web/src/tests/migration-destination.test.ts b/apps/web/src/tests/migration-destination.test.ts new file mode 100644 index 000000000..45049a1d5 --- /dev/null +++ b/apps/web/src/tests/migration-destination.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' + +import { + migrationDestination, + migrationNavigateOptions, +} from '#/lib/migration-navigation' + +/** + * Where a validated internal path actually leads while half of VitNode still + * runs on Next.js. + * + * The decision only - `isOwned` is an argument here, exactly as it is in the + * function, because answering it needs a live router and this rule has to be + * makeable on the server as well as in the browser. What the route tree answers + * for a given URL is pinned in `plugin-routes.test.ts`. + * + * Two questions are kept apart on purpose, and this file only exercises the + * second one: + * + * safe - may this app send a browser here? `auth-return-to.test.ts` + * owned - which application serves it? here + */ + +const LEGACY_ORIGIN = 'http://localhost:3000' + +describe('a destination this app owns', () => { + it('becomes a router navigation, with no locale of its own', () => { + // Un-prefixed on purpose: `rewrite.output` writes the locale when the + // location is built, so `/pl/pl/discover` is not a shape this can produce. + expect( + migrationDestination({ + href: '/discover', + isOwned: true, + legacyOrigin: LEGACY_ORIGIN, + locale: 'pl', + }), + ).toEqual({ destination: { to: '/discover' }, type: 'tanstack' }) + }) + + it('preserves the search parameters', () => { + expect( + migrationDestination({ + href: '/discover?sort=new', + isOwned: true, + locale: 'en', + }), + ).toEqual({ + destination: { search: { sort: 'new' }, to: '/discover' }, + type: 'tanstack', + }) + }) + + it('preserves the hash', () => { + expect( + migrationDestination({ + href: '/discover?sort=new#feed', + isOwned: true, + locale: 'en', + }), + ).toEqual({ + destination: { + hash: 'feed', + search: { sort: 'new' }, + to: '/discover', + }, + type: 'tanstack', + }) + }) + + /** + * The route tree has no locale in it, so what the router is handed must not + * either - `rewrite.output` writes the prefix back when the location is built. + * + * The normal flow already produces an internal path: `returnTo` is built from + * `location.pathname`, which the rewrite has already stripped. This is about + * the spelling nothing stops a visitor from typing - + * `/pl/login?returnTo=/pl/discover` - which `sanitizeReturnTo` accepts because + * it is a perfectly safe application path. Handing `/pl/discover` to the + * router as `to` would name a route that does not exist. + */ + it('strips a locale prefix somebody put in the returnTo', () => { + expect( + migrationDestination({ + href: '/pl/discover', + isOwned: true, + locale: 'pl', + }), + ).toEqual({ destination: { to: '/discover' }, type: 'tanstack' }) + }) + + it('strips the prefix and keeps the search and hash', () => { + expect( + migrationDestination({ + href: '/pl/discover?sort=new#feed', + isOwned: true, + locale: 'pl', + }), + ).toEqual({ + destination: { + hash: 'feed', + search: { sort: 'new' }, + to: '/discover', + }, + type: 'tanstack', + }) + }) + + /** + * Stage 3's own rule, not a prefix check written here: `/admin` carries no + * locale in the first place, so there is nothing to strip and a segment that + * merely looks like one is left alone. + */ + it('leaves a path outside the localized URL space alone', () => { + expect( + migrationDestination({ + href: '/admin/core', + isOwned: true, + locale: 'pl', + }), + ).toEqual({ destination: { to: '/admin/core' }, type: 'tanstack' }) + }) + + it('does not mistake an unrelated first segment for a locale', () => { + expect( + migrationDestination({ href: '/plugins', isOwned: true, locale: 'pl' }), + ).toEqual({ destination: { to: '/plugins' }, type: 'tanstack' }) + }) +}) + +describe('a destination the legacy application still serves', () => { + it('becomes a full URL at the legacy origin, localized exactly once', () => { + expect( + migrationDestination({ + href: '/settings/security?tab=devices', + isOwned: false, + legacyOrigin: LEGACY_ORIGIN, + locale: 'pl', + }), + ).toEqual({ + href: 'http://localhost:3000/pl/settings/security?tab=devices', + type: 'legacy', + }) + }) + + it('takes no prefix for the default locale', () => { + expect( + migrationDestination({ + href: '/settings/security', + isOwned: false, + legacyOrigin: LEGACY_ORIGIN, + locale: 'en', + }), + ).toEqual({ + href: 'http://localhost:3000/settings/security', + type: 'legacy', + }) + }) + + it('preserves the hash', () => { + expect( + migrationDestination({ + href: '/blog/post-1#comments', + isOwned: false, + legacyOrigin: LEGACY_ORIGIN, + locale: 'en', + }), + ).toEqual({ + href: 'http://localhost:3000/blog/post-1#comments', + type: 'legacy', + }) + }) + + it('keeps exactly one prefix on an href that already carries it', () => { + // `buildLegacyHref` localizes with the same Stage 3 rule, and that rule is + // idempotent - so the legacy branch deliberately does *not* de-localize + // first. `/pl/pl/...` is not a shape this can produce. + expect( + migrationDestination({ + href: '/pl/settings/security', + isOwned: false, + legacyOrigin: LEGACY_ORIGIN, + locale: 'pl', + }), + ).toEqual({ + href: 'http://localhost:3000/pl/settings/security', + type: 'legacy', + }) + }) + + it('stays relative when no legacy origin is configured', () => { + // The deployment where a proxy in front of both apps routes by path. The + // navigation still has to leave this router, which is what + // `migrationNavigateOptions` insists on below. + expect( + migrationDestination({ + href: '/settings', + isOwned: false, + locale: 'pl', + }), + ).toEqual({ href: '/pl/settings', type: 'legacy' }) + }) + + it('never takes an origin from the path it was given', () => { + // The origin is application configuration. `sanitizeReturnTo` has already + // refused anything that could name one, and this is the second lock: a path + // is resolved *against* the configured origin, never trusted to supply one. + const { href } = migrationDestination({ + href: '/settings', + isOwned: false, + legacyOrigin: LEGACY_ORIGIN, + locale: 'en', + }) as { href: string } + + expect(new URL(href).origin).toBe(LEGACY_ORIGIN) + }) +}) + +describe('turning a destination into redirect or navigate options', () => { + it('hands an owned destination straight through', () => { + expect( + migrationNavigateOptions({ + destination: { search: { sort: 'new' }, to: '/discover' }, + type: 'tanstack', + }), + ).toEqual({ search: { sort: 'new' }, to: '/discover' }) + }) + + /** + * `reloadDocument` is set rather than inferred. An absolute href infers it on + * its own, but a relative legacy href - the no-configured-origin deployment - + * would not, and inferring nothing there turns the one navigation that must + * leave this router into a client-side one to a route it cannot render. + */ + it.each(['http://localhost:3000/pl/settings', '/pl/settings'])( + 'always leaves the router for the legacy href %s', + (href) => { + expect(migrationNavigateOptions({ href, type: 'legacy' })).toEqual({ + href, + reloadDocument: true, + }) + }, + ) +}) diff --git a/apps/web/src/tests/plugin-routes.test.ts b/apps/web/src/tests/plugin-routes.test.ts index e8c77fef0..980c0a704 100644 --- a/apps/web/src/tests/plugin-routes.test.ts +++ b/apps/web/src/tests/plugin-routes.test.ts @@ -5,7 +5,7 @@ import type { PluginRoute } from '@vitnode/core/routing' import { createRootRoute, createRoute } from '@tanstack/react-router' import { describe, expect, it } from 'vitest' -import { isTanStackOwnedPath } from '#/components/migration-link' +import { isTanStackOwnedPath } from '#/lib/migration-navigation' import { assertPluginRouteModule, fileRoutePaths, @@ -375,10 +375,74 @@ describe("the app's real route tree", () => { ['/discover', true], ['/blog/post-30', false], ['/api/core/members', false], + // Stage 6. `/login` is migrated; the two auth routes nested *under* it are + // not, and owning the parent must not make them look owned - see below. + ['/login', true], + ['/pl/login', true], + ['/login/sso/google', true], + ['/login/reset-password', false], + ['/register', false], + // Behind `_authenticated`, which is pathless: the guard adds no segment, so + // the page is owned at its own path and the boundary is invisible here. + ['/account', true], ])('answers %s as owned: %s', (href, owned) => { expect(isTanStackOwnedPath(getRouter(), href)).toBe(owned) }) + /** + * Owning `/login` must not quietly annex the legacy routes beneath it. + * + * If the SSO callback were a *child* of `/login`, that route would match + * `/login/reset-password` as a prefix too, and `MigrationLink` would hand a + * page the Next.js app still serves to this router as a client-side + * navigation - a working password reset turning into a TanStack not-found. + * The callback is therefore a non-nested sibling + * (`routes/login_.sso.$providerId.tsx`), which is what these two assertions + * pin: two exact leaves, no shared parent. + */ + it('keeps /login an exact match, so the legacy routes under it stay legacy', () => { + const router = getRouter() + const deepest = (pathname: string) => + router.matchRoutes(pathname, undefined).at(-1) as { + pathname: string + routeId: string + } + + // `/login` resolves to itself, having consumed the whole path. + expect(deepest('/login')).toMatchObject({ + pathname: '/login', + routeId: '/login', + }) + + // `/login/reset-password` resolves to `/login` as well - `matchRoutes` + // answers with the deepest *ancestor* it can match and leaves the rest + // unconsumed. Which is exactly why `isTanStackOwnedPath` compares the + // matched pathname to the requested one instead of counting matches: the + // route id alone says "owned" here, and it is not. + expect(deepest('/login/reset-password')).toMatchObject({ + pathname: '/login', + routeId: '/login', + }) + expect(isTanStackOwnedPath(router, '/login/reset-password')).toBe(false) + }) + + /** + * The SSO callback must not sit behind the guest-only guard. + * + * By the time a provider redirects back, the API has minted its `--state-sso` + * cookie and the visitor may already have been signed in by another tab. Under + * `/login`'s guard, a signed-in visitor arriving with a valid `code` would be + * redirected away before the exchange ran, abandoning a half-finished OAuth + * round trip. Asserted as route *structure*, which is what decides it. + */ + it('does not put the SSO callback under the login route', () => { + expect( + getRouter() + .matchRoutes('/login/sso/google', undefined) + .map((match) => match.routeId), + ).not.toContain('/login') + }) + /** * A plugin route must not be reachable at a URL nobody declared. `/pl/example` * works because Stage 3's rewrite strips the prefix before matching; an unknown diff --git a/apps/web/src/tests/session-query.test.ts b/apps/web/src/tests/session-query.test.ts new file mode 100644 index 000000000..de8f0b440 --- /dev/null +++ b/apps/web/src/tests/session-query.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { sessionQueryOptions } from '#/lib/auth/query' +import { SESSION_QUERY_KEY } from '#/lib/auth/shared' + +/** + * The canonical session query's policy, as plain options. + * + * No client, no render, no request - `sessionQueryOptions()` is an object, and + * these are the two fields of it whose being wrong is silent. A missing + * `retry: false` costs nothing that a test can see and everything in production: + * a rate-limited session read would be sent twice more before the route could + * report anything, which is both slower and precisely what the limiter asked + * this app to stop doing. + * + * This is the one auth test that loads `#/lib/auth/query` at runtime rather than + * as a type. It reaches the server fetcher module through `#/lib/session`, which + * is why the other auth tests import `SessionApi` type-only - there is nothing + * to execute here, only an options object to read back. + */ +describe('the canonical session query', () => { + it('asks once and lets the failure surface', () => { + expect(sessionQueryOptions().retry).toBe(false) + }) + + it('is the one entry every guard and component reads', () => { + expect(sessionQueryOptions().queryKey).toEqual(SESSION_QUERY_KEY) + }) +}) diff --git a/packages/vitnode/scripts/plugin.ts b/packages/vitnode/scripts/plugin.ts index bcfaaa920..219505953 100644 --- a/packages/vitnode/scripts/plugin.ts +++ b/packages/vitnode/scripts/plugin.ts @@ -20,6 +20,24 @@ import { type SourceConfig, } from "./shared/file-utils.js"; +/** + * Whether an app is a Next.js App Router app - the only kind that wants route + * files copied into `src/app/`. + * + * `vitnode.config.ts` is no longer enough on its own: a TanStack Start app has + * one too (`apps/web`), and copying Next.js pages into it fills `src/app/` with + * `next/*` imports that nothing renders - a confusing directory at best, and a + * failing Next-free boundary test at worst. That app mounts a plugin's pages + * through its own route tree (`vitnode-plugin-routes.ts`) and needs nothing from + * here. + * + * A Next config is the marker, because it is the one file only Next.js reads. + */ +const isNextApp = (appPath: string): boolean => + ["next.config.js", "next.config.mjs", "next.config.ts"].some(name => + existsSync(join(appPath, name)), + ); + /** * Helper: detect if an app path is web, api, or null */ @@ -30,7 +48,7 @@ const detectAppType = (appPath: string) => { ); if (hasApiConfig && !hasWebConfig) return "api"; - if (hasWebConfig) return "web"; + if (hasWebConfig && isNextApp(appPath)) return "web"; return null; }; diff --git a/packages/vitnode/scripts/prepare-plugins-files.ts b/packages/vitnode/scripts/prepare-plugins-files.ts index d7eb49257..e6777aa2a 100644 --- a/packages/vitnode/scripts/prepare-plugins-files.ts +++ b/packages/vitnode/scripts/prepare-plugins-files.ts @@ -14,6 +14,24 @@ import { type SourceConfig, } from "./shared/file-utils.js"; +/** + * Whether an app is a Next.js App Router app - the only kind that wants route + * files copied into `src/app/`. + * + * `vitnode.config.ts` is no longer enough on its own: a TanStack Start app has + * one too (`apps/web`), and copying Next.js pages into it fills `src/app/` with + * `next/*` imports that nothing renders - a confusing directory at best, and a + * failing Next-free boundary test at worst. That app mounts a plugin's pages + * through its own route tree (`vitnode-plugin-routes.ts`) and needs nothing from + * here. + * + * A Next config is the marker, because it is the one file only Next.js reads. + */ +const isNextApp = (appPath: string): boolean => + ["next.config.js", "next.config.mjs", "next.config.ts"].some(name => + existsSync(join(appPath, name)), + ); + export const preparePluginsFiles = async (flag?: string) => { // Detect which config file to load based on flag or auto-detection const cwd = process.cwd(); @@ -95,7 +113,7 @@ export const preparePluginsFiles = async (flag?: string) => { ); if (hasApiConfig && !hasWebConfig) return "api"; - if (hasWebConfig) return "web"; + if (hasWebConfig && isNextApp(appPath)) return "web"; return null; }; diff --git a/packages/vitnode/src/hooks/use-captcha.ts b/packages/vitnode/src/hooks/use-captcha.ts index 5cde50a6c..c3042c2bf 100644 --- a/packages/vitnode/src/hooks/use-captcha.ts +++ b/packages/vitnode/src/hooks/use-captcha.ts @@ -3,12 +3,11 @@ /* eslint-disable react-you-might-not-need-an-effect/no-adjust-state-on-prop-change */ import type { z } from "zod"; -import { useLocale, useTranslations } from "next-intl"; import React from "react"; import { toast } from "sonner"; +import { useLocale, useTranslations } from "use-intl"; import { useTheme } from "@/components/theme-provider"; -import { usePathname } from "@/lib/navigation"; import type { routeMiddlewareSchema } from "../api/modules/middleware/route"; @@ -37,6 +36,28 @@ declare global { } } +/** + * Loading a captcha widget, without knowing which framework is rendering it. + * + * This hook is reached by every `AutoForm`, so what it imports decides what an + * `AutoForm` can be rendered by - and it used to import `@/lib/navigation`, + * which is built on `next-intl/navigation` and `next-intl/server`. That single + * line made the whole form stack Next-only: the shared sign-in form could not be + * mounted from a TanStack Start route, because resolving it reached + * `next/headers`. + * + * The pathname it read was an effect dependency and nothing else - "tear the + * widget down and inject it again when the URL changes". Mounting already does + * that: each of the three forms that ask for a captcha lives on its own route, + * so a navigation unmounts one and mounts the next, and the effect's cleanup and + * setup run either way. What it did *not* cover is a language switch, which is + * still a dependency below because the widget is rendered in the visitor's + * language. + * + * `use-intl` rather than `next-intl` for the same reason - the same module + * record either way, and one that a TanStack Start app can resolve. + */ + export const useCaptcha = ( captcha: z.infer["captcha"], ) => { @@ -45,7 +66,6 @@ export const useCaptcha = ( const { resolvedTheme } = useTheme(); const [isReady, setIsReady] = React.useState(false); const [token, setToken] = React.useState(""); - const pathname = usePathname(); const onReset = () => { if (!captcha) return; @@ -130,7 +150,7 @@ export const useCaptcha = ( } }; // eslint-disable-next-line @eslint-react/exhaustive-deps - }, [pathname, locale, captcha?.type, captcha?.siteKey]); + }, [locale, captcha?.type, captcha?.siteKey]); const getToken = async (): Promise => { if (!captcha) return ""; diff --git a/packages/vitnode/src/views/auth/auth-boundaries.test.ts b/packages/vitnode/src/views/auth/auth-boundaries.test.ts new file mode 100644 index 000000000..2a7ff9a9d --- /dev/null +++ b/packages/vitnode/src/views/auth/auth-boundaries.test.ts @@ -0,0 +1,264 @@ +// @vitest-environment node +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); +const srcRoot = resolve(here, "../.."); + +/** + * The auth screens, split down the middle. + * + * The same boundary `feed-boundaries.test.ts` draws around the search feed, for + * the same reason and with the same machinery: a shared component that reaches + * `@/lib/navigation` - or anything else built on Next's request scope - cannot + * be rendered by a TanStack Start route, and nothing about that failure is + * visible until somebody tries. + */ +const SHARED = { + card: join(here, "sign-in/sign-in-content.tsx"), + errorScreen: join(here, "../error/error-content.tsx"), + signInForm: join(here, "sign-in/form/sign-in-form-content.tsx"), + ssoButtons: join(here, "sso/buttons/sso-buttons-content.tsx"), + ssoCallback: join(here, "sso/callback/sso-callback-content.tsx"), + ssoCallbackHook: join(here, "sso/callback/use-sso-callback.ts"), +}; + +/** The Next.js half: server actions, `next/cache`, locale-aware navigation. */ +const NEXT_WRAPPERS = { + card: join(here, "sign-in/sign-in-card.tsx"), + signInForm: join(here, "sign-in/form/form.tsx"), + ssoButtons: join(here, "sso/buttons/client.tsx"), + ssoCallback: join(here, "sso/callback/client/client.tsx"), +}; + +const resolveSpecifier = (specifier: string, from: string): null | string => { + let base: string; + + if (specifier.startsWith("@/")) base = join(srcRoot, specifier.slice(2)); + else if (specifier.startsWith(".")) base = resolve(dirname(from), specifier); + else return null; + + for (const suffix of [".ts", ".tsx", "/index.ts", "/index.tsx"]) { + const candidate = base + suffix; + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + + return existsSync(base) && statSync(base).isFile() ? base : null; +}; + +/** + * Every specifier a file imports **at runtime**. + * + * `import type` statements are stripped first: a wrapper imports the *type* of + * its server action's input, which is erased at compile time and never reaches + * a bundle. + */ +const runtimeImports = (path: string): string[] => { + const source = readFileSync(path, "utf8").replace( + /(^|[\n;])\s*import\s+type\s[\s\S]*?from\s*["'][^"']+["']/g, + "$1", + ); + + return [ + ...source.matchAll( + /(?:^|[^\w$.])from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']|(?:^|[\n;}])\s*import\s*["']([^"']+)["']/g, + ), + ] + .map(match => match[1] ?? match[2] ?? match[3]) + .filter((specifier): specifier is string => Boolean(specifier)); +}; + +/** Every external specifier reachable from an entry, with the chain that got there. */ +const externalGraph = (entry: string): Map => { + const found = new Map(); + const parents = new Map(); + const seen = new Set(); + + const chain = (file: string): string => { + const parts: string[] = []; + for (let at: string | undefined = file; at; at = parents.get(at)) { + parts.unshift(relative(srcRoot, at)); + } + + return parts.join(" -> "); + }; + + const walk = (file: string) => { + if (seen.has(file)) return; + seen.add(file); + + for (const specifier of runtimeImports(file)) { + const target = resolveSpecifier(specifier, file); + + if (target) { + if (!parents.has(target)) parents.set(target, file); + walk(target); + continue; + } + + found.set(specifier, [...(found.get(specifier) ?? []), chain(file)]); + } + }; + + walk(entry); + + return found; +}; + +const matches = (specifier: string, forbidden: string): boolean => + specifier === forbidden || specifier.startsWith(`${forbidden}/`); + +const offenders = (entry: string, forbidden: string[]): string[] => + [...externalGraph(entry)] + .filter(([specifier]) => forbidden.some(one => matches(specifier, one))) + .flatMap(([specifier, chains]) => chains.map(at => `${specifier} in ${at}`)) + .sort(); + +/** Anything that only resolves inside a Next.js app. */ +const NEXT_ONLY = ["next", "server-only"]; + +/** + * `next-intl`'s Next-only halves. + * + * The root entry is deliberately absent: it re-exports `use-intl`, which is + * framework-free, and `apps/web` already renders core components that import it + * (`ClientButton`, `AutoForm`). These four reach for Next's request scope, its + * middleware or its build plugin - and `lib/navigation` is built on two of them. + */ +const NEXT_INTL_RUNTIME = [ + "next-intl/middleware", + "next-intl/navigation", + "next-intl/plugin", + "next-intl/server", +]; + +const sharedEntries = Object.entries(SHARED).map(([name, path]) => ({ + name, + path, +})); + +describe("the import scan finds what it is looking for", () => { + // Every assertion below is a "found nothing" one, which a scanner that + // silently matches nothing also satisfies. The Next wrappers are the control: + // they provably import the things the shared views must not. + it("finds the Next-only imports in the Next wrappers", () => { + expect(offenders(NEXT_WRAPPERS.signInForm, NEXT_INTL_RUNTIME)).not.toEqual( + [], + ); + expect(offenders(NEXT_WRAPPERS.ssoButtons, NEXT_ONLY)).not.toEqual([]); + }); + + it("walks past the entry file into its dependencies", () => { + // `lib/navigation` is two hops from the wrapper, not one. + expect( + offenders(NEXT_WRAPPERS.card, ["next-intl/navigation"]).join(), + ).toContain("next-link"); + }); +}); + +describe("the shared auth views are framework-neutral", () => { + it.each(sharedEntries)("$name reaches nothing from next/*", ({ path }) => { + expect(offenders(path, NEXT_ONLY)).toEqual([]); + }); + + it.each(sharedEntries)( + "$name reaches none of next-intl's Next-only entrypoints", + ({ path }) => { + expect(offenders(path, NEXT_INTL_RUNTIME)).toEqual([]); + }, + ); + + it.each(sharedEntries)( + "$name never reaches the locale-aware navigation module", + ({ path }) => { + const reached = [...externalGraph(path).keys()]; + + expect(reached.some(one => one.includes("navigation"))).toBe(false); + }, + ); + + it.each(sharedEntries)("$name never reaches a server action", ({ path }) => { + // A `"use server"` module is the other way Next.js gets in: importing one + // pulls the fetcher, `next/headers` and the whole API module graph behind + // it. Every mutation on these screens is a prop instead. + const reached = [...externalGraph(path).keys()]; + + expect(reached.some(one => one.endsWith(".server"))).toBe(false); + expect(runtimeImports(path).some(one => one.includes(".server"))).toBe( + false, + ); + }); +}); + +describe("the shared views take their framework parts as props", () => { + const withoutComments = (path: string): string => + readFileSync(path, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, ""); + + it("asks for a sign-in callback rather than calling a mutation", () => { + const code = withoutComments(SHARED.signInForm); + + expect(code).toContain("onSignIn"); + expect(code).not.toContain("mutationApi"); + }); + + it("asks for a provider callback rather than starting the flow itself", () => { + const code = withoutComments(SHARED.ssoButtons); + + expect(code).toContain("onSelectProvider"); + expect(code).not.toContain("mutationApi"); + }); + + it("takes its links as a component in every view that renders one", () => { + for (const path of [SHARED.card, SHARED.signInForm, SHARED.ssoCallback]) { + expect(withoutComments(path)).toContain("LinkComponent"); + } + }); + + it("renders the callback from a state rather than owning the request", () => { + const code = withoutComments(SHARED.ssoCallback); + + expect(code).toContain("state: SSOCallbackState;"); + expect(code).not.toContain("useQuery"); + }); + + it("keeps the error screen free of both translations and navigation", () => { + const code = withoutComments(SHARED.errorScreen); + + expect(code).not.toContain("useTranslations"); + expect(code).toContain("actions?: React.ReactNode;"); + }); +}); + +describe("the Next wrappers keep the Next-only pieces", () => { + it("is the only half that knows about next-intl navigation", () => { + expect(offenders(NEXT_WRAPPERS.card, ["next-intl/navigation"])).not.toEqual( + [], + ); + expect(offenders(SHARED.card, ["next-intl/navigation"])).toEqual([]); + }); + + it.each( + Object.entries(NEXT_WRAPPERS).map(([name, path]) => ({ name, path })), + )("the $name wrapper is where Next.js enters", ({ path }) => { + expect(offenders(path, [...NEXT_ONLY, ...NEXT_INTL_RUNTIME])).not.toEqual( + [], + ); + }); + + it("keeps the server actions on its own side", () => { + expect( + runtimeImports(NEXT_WRAPPERS.signInForm).some(one => + one.includes("mutation-api.server"), + ), + ).toBe(true); + expect( + runtimeImports(NEXT_WRAPPERS.ssoCallback).some(one => + one.includes("mutation-api.server"), + ), + ).toBe(true); + }); +}); diff --git a/packages/vitnode/src/views/auth/auth-link.ts b/packages/vitnode/src/views/auth/auth-link.ts new file mode 100644 index 000000000..e91933393 --- /dev/null +++ b/packages/vitnode/src/views/auth/auth-link.ts @@ -0,0 +1,45 @@ +/** + * The one thing the auth screens cannot decide for themselves. + * + * Every link on the login card points somewhere VitNode owns - `/register`, + * `/login/reset-password`, `/login` - and turning one of those paths into a + * navigation is the single question whose answer differs between the two + * frameworks: Next.js wants `next-intl`'s locale-aware `Link` + * (`@/lib/navigation`), TanStack Start wants the router's own, and during the + * migration it wants one that decides per href whether this app can render the + * destination at all. Both are a component taking {@link AuthLinkProps}, so the + * shared views take one and stop caring - and importing neither is what lets a + * TanStack Start route render the login card. + * + * The same boundary `SearchFeedContent` and `HeaderContent` already draw, for + * the same reason. + */ + +/** + * The anchor a shared auth link ends up rendering. + * + * Every prop of one, not just `href`: `SSOCallbackContent` puts a link inside a + * Base UI `render`, which clones the element with the children, the class name + * and the ref it needs to stay a button. A wrapper that accepted only `href` + * would drop all three, so the type says so. + */ +export interface AuthLinkProps extends Omit, "href"> { + href: string; +} + +export type AuthLinkComponent = (props: AuthLinkProps) => React.ReactNode; + +/** + * Where the auth screens link to by default. + * + * Ordinary data rather than a route table: a caller that mounts the login card + * somewhere else overrides the one href it moved, and nothing here has to know + * about it. None of these routes is migrated in this stage - in TanStack Start + * they are reached through the migration link, which loads the Next.js app that + * still serves them. + */ +export const AUTH_HREF = { + resetPassword: "/login/reset-password", + signIn: "/login", + signUp: "/register", +} as const; diff --git a/packages/vitnode/src/views/auth/next-link.tsx b/packages/vitnode/src/views/auth/next-link.tsx new file mode 100644 index 000000000..bae09dce7 --- /dev/null +++ b/packages/vitnode/src/views/auth/next-link.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Link } from "@/lib/navigation"; + +import type { AuthLinkProps } from "./auth-link"; + +/** + * The auth screens' link, the Next.js way: `next-intl`'s locale-aware `Link`. + * + * One module rather than one per screen, so the login card, the reset-password + * field and the SSO callback all render the same component type - and so there + * is a single place where Next.js navigation enters the auth views at all. + */ +export const NextAuthLink = ({ children, href, ...props }: AuthLinkProps) => ( + + {children} + +); diff --git a/packages/vitnode/src/views/auth/sign-in/form/form.tsx b/packages/vitnode/src/views/auth/sign-in/form/form.tsx index 7cae30a4a..dfb64a5c5 100644 --- a/packages/vitnode/src/views/auth/sign-in/form/form.tsx +++ b/packages/vitnode/src/views/auth/sign-in/form/form.tsx @@ -1,79 +1,35 @@ "use client"; -import { AlertCircle } from "lucide-react"; -import { motion, useReducedMotion } from "motion/react"; -import { useTranslations } from "next-intl"; - -import { AutoForm } from "@/components/form/auto-form"; -import { AutoFormInput } from "@/components/form/fields/input"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { SHAKE_KEYFRAMES, SHAKE_TRANSITION } from "@/lib/motion"; -import { Link } from "@/lib/navigation"; - -import { useFormSignIn } from "./use-form"; +import { NextAuthLink } from "../../next-link"; +import { mutationApi } from "./mutation-api.server"; +import { SignInFormContent } from "./sign-in-form-content"; +/** + * {@link SignInFormContent}, wired to Next.js. + * + * The props are unchanged, so the AdminCP sign-in screen sees exactly the + * component it always did. This supplies the two things the shared form cannot + * resolve for itself: + * + * - **The mutation.** A server action that signs in, revalidates the layout the + * session is rendered into and redirects - all three of which are Next.js + * APIs, and all three of which stay on this side of the boundary. `isAdmin` + * travels with it because the mutation is the only thing that ever cared: + * it decides which layout to revalidate and where to land. + * - **A `Link`** that knows how to write a locale prefix into an internal href. + * `/login/reset-password` is not migrated in this stage and is not touched + * here. + */ export const FormSignIn = ({ isAdmin, isEmail, }: { isAdmin?: boolean; isEmail: boolean; -}) => { - const t = useTranslations("core.auth.sign_in"); - const shouldReduceMotion = useReducedMotion(); - const { onSubmit, error, formSchema } = useFormSignIn({ isAdmin }); - - return ( -
- {error && ( - - - - {t(`errors.${error}.title`)} - {t(`errors.${error}.desc`)} - - - )} - - ( - - ), - }, - { - id: "password", - component: props => ( - - {t("password.reset")} - - ) : undefined - } - type="password" - {...props} - /> - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - className: "w-full", - children: t("submit"), - }} - /> -
- ); -}; +}) => ( + await mutationApi({ ...values, isAdmin })} + showResetPassword={isEmail} + /> +); diff --git a/packages/vitnode/src/views/auth/sign-in/form/schema.test.ts b/packages/vitnode/src/views/auth/sign-in/form/schema.test.ts new file mode 100644 index 000000000..6a847aa96 --- /dev/null +++ b/packages/vitnode/src/views/auth/sign-in/form/schema.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { createSignInFormSchema, signInFormOutcome } from "./schema"; + +const schema = createSignInFormSchema({ + invalidEmail: "not an email", + passwordRequired: "password missing", +}); + +describe("the sign-in schema", () => { + it("accepts an email address and a password", () => { + const parsed = schema.safeParse({ + email: "test@test.com", + password: "Test123!", + }); + + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual({ + email: "test@test.com", + password: "Test123!", + }); + }); + + it("rejects a value that is not an email address, with the message it was given", () => { + const parsed = schema.safeParse({ email: "test", password: "Test123!" }); + + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.message).toBe("not an email"); + }); + + it("rejects an empty password, with the message it was given", () => { + const parsed = schema.safeParse({ email: "test@test.com", password: "" }); + + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.message).toBe("password missing"); + }); + + it("defaults both fields to empty strings", () => { + // What `AutoForm` reads out of the JSON schema to build its default values. + // A field without one renders as an uncontrolled input and warns the first + // time it is typed into. + expect(schema.parse({ email: "a@b.com", password: "x" })).toBeDefined(); + expect(schema.shape.email.def.defaultValue).toBe(""); + expect(schema.shape.password.def.defaultValue).toBe(""); + }); + + it("carries the messages it was built with, not a fixed language", () => { + const polish = createSignInFormSchema({ + invalidEmail: "nieprawidłowy adres e-mail", + passwordRequired: "hasło jest wymagane", + }); + + expect( + polish.safeParse({ email: "test", password: "x" }).error?.issues[0] + ?.message, + ).toBe("nieprawidłowy adres e-mail"); + }); +}); + +describe("reading a sign-in result", () => { + it("says nothing happened when the mutation returned nothing", () => { + // The happy path in both frameworks: the caller redirected, so the promise + // resolves to `undefined` and there is no failure to render. + expect(signInFormOutcome(undefined)).toBeNull(); + }); + + it("shows a denial in the form rather than as a toast", () => { + expect(signInFormOutcome({ message: "access_denied" })).toEqual({ + error: "access_denied", + kind: "field", + }); + }); + + it("shows a server error as a toast rather than in the form", () => { + expect(signInFormOutcome({ message: "Internal Server Error" })).toEqual({ + kind: "toast", + }); + }); +}); diff --git a/packages/vitnode/src/views/auth/sign-in/form/schema.ts b/packages/vitnode/src/views/auth/sign-in/form/schema.ts new file mode 100644 index 000000000..844967abb --- /dev/null +++ b/packages/vitnode/src/views/auth/sign-in/form/schema.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +/** + * The sign-in form's shape and its failure vocabulary, with no React in sight. + * + * Pulled out of the hook so both halves are testable as what they are: the + * schema is a function of two already-translated strings, and the error mapping + * is a function of whatever the submit callback returned. Neither needs a + * renderer, a provider or a request to be checked. + */ + +export interface SignInFormMessages { + /** Shown when the email field is not an email address. */ + invalidEmail: string; + /** Shown when the password field is empty. */ + passwordRequired: string; +} + +/** + * The API's answer to a sign-in attempt, as the UI cares about it. + * + * `access_denied` is the one failure with a screen of its own; anything else is + * a server problem the visitor cannot act on. Kept as the literals the route + * already returns so a wrapper stays a thin translation of a status code. + */ +export type SignInMutationResult = + undefined | { message: "access_denied" | "Internal Server Error" }; + +/** What the form renders after a failed attempt, or nothing at all. */ +export type SignInFormError = "" | "access_denied"; + +export const createSignInFormSchema = ({ + invalidEmail, + passwordRequired, +}: SignInFormMessages) => + z.object({ + email: z.email({ message: invalidEmail }).default(""), + password: z.string().min(1, { message: passwordRequired }).default(""), + }); + +export type SignInFormSchema = ReturnType; +export type SignInFormValues = z.infer; + +/** + * What a submit result means for the screen. + * + * - `"field"` - a failure the visitor can fix, rendered as the alert above the + * form. Only `access_denied` qualifies today. + * - `"toast"` - a server error, rendered as the internal-error toast. + * - `null` - nothing to show: either the sign-in worked, or the caller + * navigated away and never returned a result at all. + * + * A success is deliberately indistinguishable from "returned nothing". Both the + * Next.js server action and a TanStack Start mutation redirect on success, so + * the resolved value on the happy path is `undefined` in both - which is why + * the type says `undefined` rather than `void`: a callback with nothing to + * report has to say so, and an `async` function that only ever returns a + * failure already infers exactly this. + */ +export const signInFormOutcome = ( + result: SignInMutationResult, +): null | { error: SignInFormError; kind: "field" } | { kind: "toast" } => { + if (!result?.message) return null; + + return result.message === "Internal Server Error" + ? { kind: "toast" } + : { error: result.message, kind: "field" }; +}; diff --git a/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx new file mode 100644 index 000000000..7a56f897c --- /dev/null +++ b/packages/vitnode/src/views/auth/sign-in/form/sign-in-form-content.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { AlertCircle } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { useTranslations } from "use-intl"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SHAKE_KEYFRAMES, SHAKE_TRANSITION } from "@/lib/motion"; + +import type { AuthLinkComponent } from "../../auth-link"; + +import { AUTH_HREF } from "../../auth-link"; +import { type SignInSubmit, useSignInForm } from "./use-sign-in-form"; + +export type { SignInSubmit }; + +/** + * The two fields, their validation and their failure states - shared. + * + * Everything that used to be Next-only here has become one prop. The form no + * longer imports a server action, `next/navigation` or `next-intl/navigation`: + * it is handed {@link SignInSubmit} and a way to render a link, and those are + * the only two things it cannot answer for itself. + * + * What it keeps is the whole of the experience: `AutoForm`'s per-field shake and + * submit-button state, the `access_denied` alert with its own shake, and the + * internal-error toast (in {@link useSignInForm}). The admin sign-in screen is + * the same component with no reset link, exactly as before - the "is this the + * AdminCP" flag now lives with the mutation, which is the only thing that ever + * cared. + */ +export const SignInFormContent = ({ + LinkComponent, + onSignIn, + resetPasswordHref = AUTH_HREF.resetPassword, + showResetPassword = false, +}: { + /** + * Required only alongside {@link showResetPassword}: written as an optional + * pair rather than a union because the flag is deployment configuration + * (`isEmail`) read at runtime, not something a call site knows statically. + */ + LinkComponent?: AuthLinkComponent; + onSignIn: SignInSubmit; + resetPasswordHref?: string; + /** Whether this deployment has an email adapter that can send a reset link. */ + showResetPassword?: boolean; +}) => { + const t = useTranslations("core.auth.sign_in"); + const shouldReduceMotion = useReducedMotion(); + const { error, formSchema, onSubmit } = useSignInForm({ onSignIn }); + + return ( +
+ {error && ( + + + + {t(`errors.${error}.title`)} + {t(`errors.${error}.desc`)} + + + )} + + ( + + ), + }, + { + id: "password", + component: props => ( + + {t("password.reset")} + + ) : undefined + } + type="password" + {...props} + /> + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + className: "w-full", + children: t("submit"), + }} + /> +
+ ); +}; + +/** The form's shape while the deployment configuration is still in flight. */ +export const SignInFormSkeleton = () => ( +
+
+ + +
+ +
+ + +
+ + +
+); diff --git a/packages/vitnode/src/views/auth/sign-in/form/use-form.ts b/packages/vitnode/src/views/auth/sign-in/form/use-form.ts deleted file mode 100644 index 6d6d67a03..000000000 --- a/packages/vitnode/src/views/auth/sign-in/form/use-form.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useTranslations } from "next-intl"; -import React from "react"; -import { toast } from "sonner"; -import { z } from "zod"; - -import type { AutoFormOnSubmit } from "@/components/form/auto-form"; - -import { mutationApi } from "./mutation-api.server"; - -export const useFormSignIn = ({ isAdmin = false }: { isAdmin?: boolean }) => { - const [error, setError] = React.useState<"" | "access_denied">(""); - const t = useTranslations<"core.auth.sign_in">("core.auth.sign_in"); - const tErrors = useTranslations("core.global.errors"); - const formSchema = z.object({ - email: z.email({ message: t("email.invalid") }).default(""), - password: z - .string() - .min(1, { message: t("password.required") }) - .default(""), - }); - - const onSubmit: AutoFormOnSubmit = async values => { - setError(""); - const mutation = await mutationApi({ ...values, isAdmin }); - - if (!mutation?.message) return; - if (mutation?.message !== "Internal Server Error") { - setError(mutation.message); - - return; - } - - toast.error(tErrors("title"), { - description: tErrors("internal_server_error"), - }); - }; - - return { onSubmit, error, formSchema }; -}; diff --git a/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts b/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts new file mode 100644 index 000000000..3263d9089 --- /dev/null +++ b/packages/vitnode/src/views/auth/sign-in/form/use-sign-in-form.ts @@ -0,0 +1,71 @@ +"use client"; + +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; + +import type { + SignInFormError, + SignInFormSchema, + SignInFormValues, + SignInMutationResult, +} from "./schema"; + +import { createSignInFormSchema, signInFormOutcome } from "./schema"; + +/** + * How the form asks for a session. + * + * The whole of the framework boundary for signing in, and deliberately one + * function: it takes the two field values and answers what went wrong, or + * nothing at all. What it does on success - set a cookie, revalidate a layout, + * navigate - is entirely the caller's business, which is why nothing here + * handles it. Next.js redirects from a server action; TanStack Start calls the + * API and moves the router. + */ +export type SignInSubmit = ( + values: SignInFormValues, +) => Promise; + +/** + * The sign-in form's behaviour, with no idea which framework is rendering it. + * + * `use-intl` rather than `next-intl` for the strings - the same record either + * way (`next-intl`'s client entry *is* `use-intl` re-exported), so a Next.js + * page under `NextIntlClientProvider` and a TanStack Start route under + * `IntlProvider` both resolve them. + * + * The schema is rebuilt on every render, as it always was: its messages are + * translated strings, so a memoised one would keep the previous language after + * a switch. + */ +export const useSignInForm = ({ onSignIn }: { onSignIn: SignInSubmit }) => { + const [error, setError] = React.useState(""); + const t = useTranslations("core.auth.sign_in"); + const tErrors = useTranslations("core.global.errors"); + const formSchema = createSignInFormSchema({ + invalidEmail: t("email.invalid"), + passwordRequired: t("password.required"), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + setError(""); + const outcome = signInFormOutcome(await onSignIn(values)); + + if (!outcome) return; + + if (outcome.kind === "field") { + setError(outcome.error); + + return; + } + + toast.error(tErrors("title"), { + description: tErrors("internal_server_error"), + }); + }; + + return { error, formSchema, onSubmit }; +}; diff --git a/packages/vitnode/src/views/auth/sign-in/sign-in-card.tsx b/packages/vitnode/src/views/auth/sign-in/sign-in-card.tsx new file mode 100644 index 000000000..561485268 --- /dev/null +++ b/packages/vitnode/src/views/auth/sign-in/sign-in-card.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { NextAuthLink } from "../next-link"; +import { SignInContent } from "./sign-in-content"; + +/** + * {@link SignInContent}, wired to Next.js. + * + * A client component with two slots, and that shape is load bearing. The card + * itself has to be one - it reads its strings from the client context that + * `I18nProvider` mounts, and a component type such as `LinkComponent` cannot + * cross the server/client boundary as a prop, so the choice of link is made + * here rather than passed in from the page. + * + * `form` and `sso` still arrive as *elements*, which do cross it: they are the + * Server Components that read the deployment configuration, each already + * wrapped in its own `` by `SignInView`. So the card renders in the + * browser while the two things that need a request keep streaming in from the + * server, exactly as they did before. + * + * `/register` is not migrated in this stage and is not touched here. + */ +export const SignInCard = ({ + form, + sso, +}: { + form: React.ReactNode; + sso?: React.ReactNode; +}) => ; diff --git a/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx b/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx new file mode 100644 index 000000000..13a0f61b1 --- /dev/null +++ b/packages/vitnode/src/views/auth/sign-in/sign-in-content.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useTranslations } from "use-intl"; + +import { Card, CardDescription } from "@/components/ui/card"; + +import type { AuthLinkComponent } from "../auth-link"; + +import { AUTH_HREF } from "../auth-link"; + +/** + * The login card - the heading, the copy, and the two slots that fill it. + * + * Presentation only, and framework-free on purpose: it reaches nothing from + * `next/*`, from `next-intl`'s Next-only entries or from `@/lib/navigation`, so + * a TanStack Start route renders exactly the card the Next.js page renders. + * + * `form` and `sso` are slots rather than imports because *when* each arrives + * differs by framework, not what it looks like. Next.js reads the deployment + * configuration in a Server Component and hands each one down inside its own + * `` (the skeletons live with the components they stand in for, so + * both frameworks get them); a TanStack Start route has the same data from its + * loader before this renders at all, and passes the finished elements. + * + * Everything else - the strings, the layout, the footer - is here once. + */ +export const SignInContent = ({ + LinkComponent, + form, + signUpHref = AUTH_HREF.signUp, + sso, +}: { + form: React.ReactNode; + LinkComponent: AuthLinkComponent; + signUpHref?: string; + sso?: React.ReactNode; +}) => { + const t = useTranslations("core.auth.sign_in"); + const tGlobal = useTranslations("core.global"); + + return ( +
+ +
+
+

+ {tGlobal("login")} +

+ {t("desc")} +
+ + {form} + + {sso} +
+ +
+ {t.rich("do_not_have_account", { + link: text => ( + + {text} + + ), + })} +
+
+
+ ); +}; diff --git a/packages/vitnode/src/views/auth/sign-in/sign-in-view.tsx b/packages/vitnode/src/views/auth/sign-in/sign-in-view.tsx index 7ce4e2048..1d6d4931b 100644 --- a/packages/vitnode/src/views/auth/sign-in/sign-in-view.tsx +++ b/packages/vitnode/src/views/auth/sign-in/sign-in-view.tsx @@ -1,14 +1,12 @@ -import { getTranslations } from "next-intl/server"; import React from "react"; -import { Card, CardDescription } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; import { getMiddlewareApi } from "@/lib/api/get-middleware-api"; -import { Link } from "@/lib/navigation"; import { I18nProvider } from "../../../components/i18n-provider"; import { SSOButtons, SSOButtonsSkeleton } from "../sso/buttons/sso-buttons"; import { FormSignIn } from "./form/form"; +import { SignInFormSkeleton } from "./form/sign-in-form-content"; +import { SignInCard } from "./sign-in-card"; const SignInForm = async () => { const { isEmail } = await getMiddlewareApi(); @@ -16,60 +14,30 @@ const SignInForm = async () => { return ; }; -const SignInFormSkeleton = () => ( -
-
- - -
- -
- - -
- - -
+/** + * The login page for Next.js. + * + * Everything visible is `SignInContent`, shared with TanStack Start. What stays + * here is the half that is genuinely Next.js: the request-scoped message + * provider, and the two Server Components that read the deployment + * configuration - which adapters are registered, and whether an email adapter + * exists to send a reset link. Both sit inside their own `` because + * `getMiddlewareApi` waits for a real request (see its own note), so the card + * paints immediately and each part fills in when its data lands. + */ +export const SignInView = () => ( + + }> + + + } + sso={ + }> + + + } + /> + ); - -export const SignInView = async () => { - const [t, tGlobal] = await Promise.all([ - getTranslations("core.auth.sign_in"), - getTranslations("core.global"), - ]); - - return ( - -
- -
-
-

- {tGlobal("login")} -

- {t("desc")} -
- - }> - - - - }> - - -
- -
- {t.rich("do_not_have_account", { - link: text => ( - - {text} - - ), - })} -
-
-
-
- ); -}; diff --git a/packages/vitnode/src/views/auth/sign-up/sign-up-view.tsx b/packages/vitnode/src/views/auth/sign-up/sign-up-view.tsx index ee5c2933a..afff4b49d 100644 --- a/packages/vitnode/src/views/auth/sign-up/sign-up-view.tsx +++ b/packages/vitnode/src/views/auth/sign-up/sign-up-view.tsx @@ -42,7 +42,7 @@ export const SignUpView = async () => { ]); return ( - +
diff --git a/packages/vitnode/src/views/auth/sso/buttons/client.tsx b/packages/vitnode/src/views/auth/sso/buttons/client.tsx index 85a1389ac..d86a94e89 100644 --- a/packages/vitnode/src/views/auth/sso/buttons/client.tsx +++ b/packages/vitnode/src/views/auth/sso/buttons/client.tsx @@ -1,36 +1,23 @@ "use client"; -import { useTranslations } from "next-intl"; -import { toast } from "sonner"; - -import { Button } from "@/components/ui/button"; +import type { SSOProvider } from "../providers"; import { mutationApi } from "./mutation-api.server"; +import { SSOButtonsContent } from "./sso-buttons-content"; -export const ButtonSSOButtons = ({ - children, - providerId, +/** + * {@link SSOButtonsContent}, wired to Next.js. + * + * One prop wide, and that prop is the whole of the boundary: a server action + * that asks the API for the provider's authorization URL and redirects to it. + * Redirecting *is* the success path, so it never returns - a message coming + * back means the flow could not be started, and the shared row raises the + * internal-error toast. + */ +export const SSOButtonsClient = ({ + providers, }: { - children: React.ReactNode; - providerId: string; -}) => { - const tErrors = useTranslations("core.global.errors"); - - return ( - - ); -}; + providers: readonly SSOProvider[]; +}) => ( + +); diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx new file mode 100644 index 000000000..b6279e9af --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +import type { SSOProvider } from "../providers"; + +/** + * What starting an SSO flow answers with. + * + * A message means it failed; the shared row turns that into the internal-error + * toast. On the happy path the browser has already been sent to the provider, + * so there is nothing to return - the promise simply never resolves to anything + * the row can render. + */ +export type SSOStartResult = undefined | { message?: string }; + +export type SSOSelectProvider = (providerId: string) => Promise; + +/** + * The provider buttons, separated from what pressing one does. + * + * The whole of the framework boundary is `onSelectProvider`: it takes a + * provider id and answers whether the flow could be started. Next.js calls a + * server action that redirects; TanStack Start calls the API and moves the + * browser itself. Neither is imported here. + * + * The failure toast stays on this side deliberately. It is the same message for + * the same reason in both frameworks - "we could not reach the provider" - and + * a callback that only reports the failure keeps every wrapper from having to + * re-implement it. + */ +export const SSOButtonsContent = ({ + onSelectProvider, + providers, +}: { + onSelectProvider: SSOSelectProvider; + providers: readonly SSOProvider[]; +}) => { + const t = useTranslations("core.auth.sso"); + const tErrors = useTranslations("core.global.errors"); + + if (!providers.length) { + return null; + } + + return ( + <> +
+
+ +
+ +
+ {t("or")} +
+
+ +
+ {providers.map(provider => ( + + ))} +
+ + ); +}; + +/** The row's shape while the deployment configuration is still in flight. */ +export const SSOButtonsSkeleton = () => ( +
+ + +
+); diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons.tsx index 2cc59cb3c..afd94b685 100644 --- a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons.tsx +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons.tsx @@ -1,48 +1,20 @@ -import { getTranslations } from "next-intl/server"; - -import { Skeleton } from "@/components/ui/skeleton"; import { getMiddlewareApi } from "@/lib/api/get-middleware-api"; -import { ButtonSSOButtons } from "./client"; +import { normalizeSSOProviders } from "../providers"; +import { SSOButtonsClient } from "./client"; -export const SSOButtonsSkeleton = () => { - return ( -
- - -
- ); -}; +export { SSOButtonsSkeleton } from "./sso-buttons-content"; +/** + * The provider row for Next.js: read the deployment configuration, render the + * shared row. + * + * A Server Component only because of the read - `getMiddlewareApi` waits for a + * real request, which is why both auth pages put this inside a ``. + * The row itself renders nothing when no adapter is registered. + */ export const SSOButtons = async () => { - const [t, { sso }] = await Promise.all([ - getTranslations("core.auth.sso"), - getMiddlewareApi(), - ]); - - if (!sso.length) { - return null; - } - - return ( - <> -
-
- -
- -
- {t("or")} -
-
+ const { sso } = await getMiddlewareApi(); -
- {sso.map(provider => ( - - {provider.name} - - ))} -
- - ); + return ; }; diff --git a/packages/vitnode/src/views/auth/sso/callback/callback-sso-view.tsx b/packages/vitnode/src/views/auth/sso/callback/callback-sso-view.tsx index 0108ee518..15b4726c8 100644 --- a/packages/vitnode/src/views/auth/sso/callback/callback-sso-view.tsx +++ b/packages/vitnode/src/views/auth/sso/callback/callback-sso-view.tsx @@ -1,11 +1,17 @@ -import { getTranslations } from "next-intl/server"; - import { I18nProvider } from "@/components/i18n-provider"; -import { ErrorView } from "@/views/error/error-view"; +import { getMiddlewareApi } from "@/lib/api/get-middleware-api"; -import { getMiddlewareApi } from "../../../../lib/api/get-middleware-api"; +import { normalizeSSOProviders } from "../providers"; import { ClientCallbackSSO } from "./client/client"; +/** + * The OAuth callback page for Next.js. + * + * A Server Component for one reason - reading which adapters this deployment + * registered, so the screens can name the provider rather than echo the id in + * the URL. Everything after that is the shared callback, mounted under the + * request-scoped message provider. + */ export const CallbackSSOView = async ({ providerId, searchParams: { code, error, state }, @@ -13,27 +19,17 @@ export const CallbackSSOView = async ({ providerId: string; searchParams: Record; }) => { - const [t, { sso }] = await Promise.all([ - getTranslations("core.auth.sso"), - getMiddlewareApi(), - ]); - - if (error === "access_denied") { - return ; - } + const { sso } = await getMiddlewareApi(); return ( - {error === "access_denied" ? ( - - ) : ( - - )} + ); }; diff --git a/packages/vitnode/src/views/auth/sso/callback/client/client.tsx b/packages/vitnode/src/views/auth/sso/callback/client/client.tsx index 7fa001d1f..32bc4ba4b 100644 --- a/packages/vitnode/src/views/auth/sso/callback/client/client.tsx +++ b/packages/vitnode/src/views/auth/sso/callback/client/client.tsx @@ -1,83 +1,56 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; +import { useRouter } from "@/lib/navigation"; +import { ErrorViewActions } from "@/views/error/error-view"; -import { Loader } from "@/components/ui/loader"; -import { Link, useRouter } from "@/lib/navigation"; -import { ErrorView } from "@/views/error/error-view"; +import type { SSOProvider } from "../../providers"; -import type { getMiddlewareApi } from "../../../../../lib/api/get-middleware-api"; - -import { Button } from "../../../../../components/ui/button"; +import { NextAuthLink } from "../../../next-link"; +import { SSOCallbackContent } from "../sso-callback-content"; +import { useSSOCallback } from "../use-sso-callback"; import { mutationApi } from "./mutation-api.server"; +/** + * {@link SSOCallbackContent}, wired to Next.js. + * + * The exchange and the four screens it can end on are shared; the three things + * that are not live here. The server action that trades the authorization code + * for a session and revalidates the layout it is rendered into, the router that + * takes the visitor to the front page once it worked, and the two navigation + * buttons the generic error screens end with. + */ export const ClientCallbackSSO = ({ - providerId, code, - state, - sso, + oauthError, + oauthState, + providerId, + providers, }: { code: string; + oauthError?: string; + oauthState: string; providerId: string; - sso: Awaited>["sso"]; - state: string; + providers: readonly SSOProvider[]; }) => { - const t = useTranslations("core.auth.sso"); const { replace } = useRouter(); - const { isError, error } = useQuery({ - queryKey: ["core.auth.sso.callback.sign-up", providerId, code], - queryFn: async () => { - const mutation = await mutationApi({ providerId, code, state }); - if (mutation?.error) { - throw new Error(mutation.error); - } + const state = useSSOCallback({ + code, + oauthError, + onCallback: async () => + await mutationApi({ code, providerId, state: oauthState }), + onSignedIn: () => { replace("/"); - - return ""; }, - retry: false, + providerId, }); - const provider = sso.find(p => p.id === providerId); - - if (error?.message === "Email already exists") { - return ( - } - size="lg" - > - {t("email_exists.sign_in")} - - } - customDescription={t.rich("email_exists.desc", { - provider: () => ( - - {provider?.name ?? providerId} - - ), - })} - customTitle={t.rich("email_exists.title", { - provider: () => ( - - {provider?.name ?? providerId} - - ), - })} - /> - ); - } - - if (isError) { - return ; - } return ( -
- -
+ } + LinkComponent={NextAuthLink} + providerId={providerId} + providers={providers} + state={state} + /> ); }; diff --git a/packages/vitnode/src/views/auth/sso/callback/client/mutation-api.server.ts b/packages/vitnode/src/views/auth/sso/callback/client/mutation-api.server.ts index 1da17d8c2..c746c4133 100644 --- a/packages/vitnode/src/views/auth/sso/callback/client/mutation-api.server.ts +++ b/packages/vitnode/src/views/auth/sso/callback/client/mutation-api.server.ts @@ -5,6 +5,10 @@ import { revalidatePath } from "next/cache"; import { usersModule } from "@/api/modules/users/users.module"; import { fetcher } from "@/lib/fetcher"; +import type { SSOCallbackResult } from "../sso-callback-result"; + +import { ssoCallbackResultFromStatus } from "../sso-callback-result"; + export const mutationApi = async ({ code, providerId, @@ -13,7 +17,7 @@ export const mutationApi = async ({ code: string; providerId: string; state: string; -}) => { +}): Promise => { const res = await fetcher(usersModule, { path: "/{providerId}/callback", method: "get", @@ -30,13 +34,11 @@ export const mutationApi = async ({ }, }); - if (res.status === 409) { - return { error: "Email already exists" }; - } + const result = ssoCallbackResultFromStatus(res.status); - if (res.status !== 200) { - return { error: "Something went wrong" }; + if (!result?.failure) { + revalidatePath("/[locale]/(main)", "layout"); } - revalidatePath("/[locale]/(main)", "layout"); + return result; }; diff --git a/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx b/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx new file mode 100644 index 000000000..478cb78a3 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/callback/sso-callback-content.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { Loader } from "@/components/ui/loader"; +import { ErrorContent } from "@/views/error/error-content"; + +import type { AuthLinkComponent } from "../../auth-link"; +import type { SSOProvider } from "../providers"; +import type { SSOCallbackState } from "./use-sso-callback"; + +import { AUTH_HREF } from "../../auth-link"; + +/** + * The callback screen, as a function of which of four things happened. + * + * No request and no navigation: {@link useSSOCallback} runs the exchange and + * answers with a state, and this renders it. That split is the whole point - + * the states and their copy are identical in both frameworks, while starting a + * mutation and moving the browser are not. + * + * `errorActions` is the "go back / go home" pair, which is navigation and + * therefore the wrapper's to supply. `LinkComponent` is the one link with copy + * of its own: after an email conflict the visitor is sent to the login page to + * sign in the way they did the first time. + */ +export const SSOCallbackContent = ({ + LinkComponent, + errorActions, + providerId, + providers, + signInHref = AUTH_HREF.signIn, + state, +}: { + errorActions?: React.ReactNode; + LinkComponent: AuthLinkComponent; + providerId: string; + providers: readonly SSOProvider[]; + signInHref?: string; + state: SSOCallbackState; +}) => { + const t = useTranslations("core.auth.sso"); + const tGlobal = useTranslations("core.global"); + const provider = providers.find(one => one.id === providerId); + // The provider's display name, falling back to the id in the URL: a callback + // can arrive for an adapter that was removed from the deployment, and "you + // cannot sign in with google" still reads better than an empty sentence. + const providerName = () => ( + {provider?.name ?? providerId} + ); + + if (state === "access_denied") { + return ( + + ); + } + + if (state === "email_exists") { + return ( + } + size="lg" + > + {t("email_exists.sign_in")} + + } + code={409} + description={t.rich("email_exists.desc", { provider: providerName })} + title={t.rich("email_exists.title", { provider: providerName })} + /> + ); + } + + if (state === "error") { + return ( + + ); + } + + return ( +
+ +
+ ); +}; diff --git a/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.test.ts b/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.test.ts new file mode 100644 index 000000000..fdc3243be --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { ssoCallbackResultFromStatus } from "./sso-callback-result"; + +describe("reading an SSO callback status", () => { + it("signs the visitor in on 200", () => { + expect(ssoCallbackResultFromStatus(200)).toEqual({}); + }); + + it("reports the email conflict on 409", () => { + expect(ssoCallbackResultFromStatus(409)).toEqual({ + failure: "email_exists", + }); + }); + + it("reports everything else as a failure the visitor cannot resolve", () => { + for (const status of [400, 401, 403, 404, 429, 500, 502]) { + expect(ssoCallbackResultFromStatus(status)).toEqual({ + failure: "unknown", + }); + } + }); +}); diff --git a/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.ts b/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.ts new file mode 100644 index 000000000..a54128d35 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/callback/sso-callback-result.ts @@ -0,0 +1,29 @@ +/** + * What came back from exchanging an OAuth code for a session, as the callback + * screen cares about it. + * + * Two failures and nothing else: an email that already belongs to another + * account, which the visitor can act on, and everything else, which they + * cannot. Kept as codes rather than as the sentences they used to be - the + * screen compared `error.message === "Email already exists"`, so an edit to + * that string in one file silently changed which screen the other one rendered. + */ +export type SSOCallbackFailure = "email_exists" | "unknown"; + +export type SSOCallbackResult = undefined | { failure?: SSOCallbackFailure }; + +/** + * The API's status code, read as an outcome. + * + * Pure, so the mapping is checkable without a request: 200 signed the visitor + * in, 409 is the email conflict, and anything else is a failure they cannot + * resolve. + */ +export const ssoCallbackResultFromStatus = ( + status: number, +): SSOCallbackResult => { + if (status === 200) return {}; + if (status === 409) return { failure: "email_exists" }; + + return { failure: "unknown" }; +}; diff --git a/packages/vitnode/src/views/auth/sso/callback/use-sso-callback.ts b/packages/vitnode/src/views/auth/sso/callback/use-sso-callback.ts new file mode 100644 index 000000000..de2efcb60 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/callback/use-sso-callback.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import type { SSOCallbackResult } from "./sso-callback-result"; + +/** What the callback screen is showing right now. */ +export type SSOCallbackState = + "access_denied" | "email_exists" | "error" | "pending"; + +/** + * The exchange, run once, with the framework parts held at arm's length. + * + * Two callbacks and no imports beyond Query, which is framework-free and + * already mounted in both apps: + * + * - `onCallback` sends the code and the state to the API and answers what + * happened. Next.js calls a server action; TanStack Start calls the API. + * - `onSignedIn` runs once that succeeded. Both frameworks send the visitor to + * the front page, by their own means. + * + * `retry: false` because an authorization code is single-use: a second attempt + * cannot succeed, and would turn a clear "that email is taken" into a generic + * failure. The query key carries the provider and the code, so a re-render + * never re-runs the exchange and a genuinely new callback always does. + * + * A provider that reported `access_denied` in the URL never gets that far - + * there is no code to exchange, so the query does not run at all. + */ +export const useSSOCallback = ({ + code, + oauthError, + onCallback, + onSignedIn, + providerId, +}: { + code: string; + /** The `error` parameter the provider redirected back with, if any. */ + oauthError?: string; + onCallback: () => Promise; + onSignedIn: () => void; + providerId: string; +}): SSOCallbackState => { + const denied = oauthError === "access_denied"; + const { error, isError } = useQuery({ + enabled: !denied, + queryFn: async () => { + const result = await onCallback(); + if (result?.failure) { + throw new Error(result.failure); + } + onSignedIn(); + + return ""; + }, + queryKey: ["core.auth.sso.callback.sign-up", providerId, code], + retry: false, + }); + + if (denied) return "access_denied"; + if (error?.message === "email_exists") return "email_exists"; + if (isError) return "error"; + + return "pending"; +}; diff --git a/packages/vitnode/src/views/auth/sso/providers.test.ts b/packages/vitnode/src/views/auth/sso/providers.test.ts new file mode 100644 index 000000000..bc31a0e20 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/providers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeSSOProviders } from "./providers"; + +describe("normalising the SSO provider list", () => { + it("keeps well-formed providers in the order they were registered", () => { + expect( + normalizeSSOProviders([ + { id: "google", name: "Google" }, + { id: "github", name: "GitHub" }, + ]), + ).toEqual([ + { id: "google", name: "Google" }, + { id: "github", name: "GitHub" }, + ]); + }); + + it("answers with an empty list when there is nothing to render", () => { + // A deployment with no adapters, and a loader that has not resolved: the + // button row renders nothing for both, rather than throwing on `.map`. + expect(normalizeSSOProviders([])).toEqual([]); + expect(normalizeSSOProviders(undefined)).toEqual([]); + expect(normalizeSSOProviders(null)).toEqual([]); + expect(normalizeSSOProviders({ sso: [] })).toEqual([]); + }); + + it("drops entries that cannot become a button", () => { + expect( + normalizeSSOProviders([ + { id: "google", name: "Google" }, + { id: "", name: "Nameless" }, + { id: "github" }, + { name: "GitHub" }, + "google", + null, + ]), + ).toEqual([{ id: "google", name: "Google" }]); + }); + + it("keeps the first of two providers sharing an id", () => { + // React keys the row by id, so a duplicate is a warning plus a button that + // cannot be told apart from the one above it. + expect( + normalizeSSOProviders([ + { id: "google", name: "Google" }, + { id: "google", name: "Google (staging)" }, + ]), + ).toEqual([{ id: "google", name: "Google" }]); + }); +}); diff --git a/packages/vitnode/src/views/auth/sso/providers.ts b/packages/vitnode/src/views/auth/sso/providers.ts new file mode 100644 index 000000000..445ce1ac1 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/providers.ts @@ -0,0 +1,46 @@ +/** + * An SSO provider, as the auth screens need it: something to click and + * something to call it. + * + * Ordinary typed data rather than a registry lookup. The list is deployment + * configuration - it comes from the middleware route, which derives it from + * `vitnode.api.config.ts` - so both frameworks fetch the same JSON and hand it + * straight to the shared button row. + */ +export interface SSOProvider { + id: string; + name: string; +} + +const isProvider = (value: unknown): value is SSOProvider => + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" && + typeof (value as { name?: unknown }).name === "string" && + (value as { id: string }).id !== ""; + +/** + * The provider list, made safe to render. + * + * Every caller already holds a parsed API response, so this is not validation + * so much as a single place for the three questions a button row would + * otherwise ask inline: is there a list at all (a loader that has not resolved, + * a deployment with no adapters), does each entry have the two fields a button + * needs, and is any provider listed twice - which React answers with a + * duplicate-key warning and a row that renders one button too many. + * + * Order is preserved: it is the order the adapters were registered in, which is + * the order the deployment chose. + */ +export const normalizeSSOProviders = (value: unknown): SSOProvider[] => { + if (!Array.isArray(value)) return []; + + const seen = new Set(); + + return value.filter(isProvider).filter(provider => { + if (seen.has(provider.id)) return false; + seen.add(provider.id); + + return true; + }); +}; diff --git a/packages/vitnode/src/views/error/error-content.tsx b/packages/vitnode/src/views/error/error-content.tsx new file mode 100644 index 000000000..09c5fd7f3 --- /dev/null +++ b/packages/vitnode/src/views/error/error-content.tsx @@ -0,0 +1,40 @@ +/** + * An error screen: a status code, what it means, and what to do about it. + * + * Presentation and nothing else - no translations and no navigation, which is + * what makes it renderable by both frameworks and, just as importantly, by a + * React Server Component. `ErrorView` (this file's Next.js wrapper) is rendered + * by `not-found.tsx` on the server *and* by the SSO callback in the browser, so + * the strings have to be looked up by whoever knows which of the two it is: + * `next-intl` reads Next's request scope in one and its client context in the + * other, while a TanStack Start route reads `use-intl`. Both then hand the + * finished text here. + * + * `actions` is a slot for the same reason: "go back" and "go home" are + * navigation, and navigation is the framework's business. + */ +export const ErrorContent = ({ + actions, + code, + description, + title, +}: { + actions?: React.ReactNode; + code: 400 | 403 | 404 | 409 | 429 | 500; + description?: React.ReactNode; + title?: React.ReactNode; +}) => ( +
+
+
+

{code}

+

{title}

+

{description}

+
+ +
+ {actions} +
+
+
+); diff --git a/packages/vitnode/src/views/error/error-view.tsx b/packages/vitnode/src/views/error/error-view.tsx index ac362ff61..cf6a8a32e 100644 --- a/packages/vitnode/src/views/error/error-view.tsx +++ b/packages/vitnode/src/views/error/error-view.tsx @@ -6,7 +6,49 @@ import { Link } from "@/lib/navigation"; import { cn } from "@/lib/utils"; import { BackButtonNotFound } from "./back-button"; +import { ErrorContent } from "./error-content"; +/** + * "Go back" and "go home", the Next.js way. + * + * Exported because the SSO callback needs exactly these two buttons around the + * shared error screen it renders for a denied or failed sign-in, and building + * them a second time is how the two drift apart. + */ +export const ErrorViewActions = () => { + const t = useTranslations("core.global"); + + return ( + <> + + + {t("go_back")} + + + + + {t("back_home")} + + + ); +}; + +/** + * {@link ErrorContent}, wired to Next.js. + * + * The props are unchanged, so every `not-found.tsx`, the data table's failure + * state and the route error boundary see exactly the component they always did. + * This supplies the two things the shared screen cannot resolve for itself: the + * strings (`next-intl`, which works in a Server Component *and* in the browser) + * and the default actions, which are `next-intl`'s locale-aware navigation. + */ export const ErrorView = ({ code, customDescription, @@ -21,41 +63,11 @@ export const ErrorView = ({ const t = useTranslations("core.global"); return ( -
-
-
-

{code}

-

- {customTitle ?? t(`errors.${code}.title`)} -

-

- {customDescription ?? t(`errors.${code}.desc`)} -

-
- -
- {customActions ?? ( - <> - - - {t("go_back")} - - - - - {t("back_home")} - - - )} -
-
-
+ } + code={code} + description={customDescription ?? t(`errors.${code}.desc`)} + title={customTitle ?? t(`errors.${code}.title`)} + /> ); };