Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 23 additions & 58 deletions apps/web/src/components/migration-link.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -95,30 +50,40 @@ 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<React.ComponentProps<'a'>, '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 (
<Link className={className} to={href}>
<Link {...props} to={href}>
{children}
</Link>
)
}

return (
<a
className={className}
{...props}
href={buildLegacyHref({ href, legacyOrigin: legacyWebOrigin(), locale })}
>
{children}
Expand Down
165 changes: 165 additions & 0 deletions apps/web/src/lib/auth/actions.ts
Original file line number Diff line number Diff line change
@@ -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<SSOCallbackResult> => {
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
}
}
Loading