- {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.
-
` and its description, the navigation card, the panel
+ * card, the mobile back link and the narrow-screen rule that shows the menu on
+ * `/settings` and the panel everywhere else. A panel route renders only its own
+ * contents - a heading and, in time, a form.
+ *
+ * `SettingsShellContent` and `SettingsNavContent` are the same modules the
+ * Next.js layout renders. The two things a shared component cannot resolve for
+ * itself are passed in: where the visitor is, and how to build a link.
+ *
+ * ## What a panel may assume about the provider
+ *
+ * That `RouteMessages` is above it - in its component *and in its
+ * `pendingComponent`* - so a panel's loading fallback may translate without
+ * mounting a provider of its own (`settings/devices.tsx` does). The guarantee is
+ * structural rather than incidental: a panel's `pendingComponent` is rendered
+ * into this layout's ` `, and the ` ` only exists once the
+ * function below has run, which is what mounts the provider.
+ *
+ * The one thing that is *not* covered by it is a `pendingComponent` on **this**
+ * route. There is none today, and if one is added it renders in place of the
+ * function below - above the provider, not inside it - so it must either avoid
+ * translating or mount `RouteMessages` itself. Adding one does not invalidate any
+ * panel's fallback; only this layout's own would need the extra care.
+ */
+export const Route = createFileRoute('/_main/_authenticated/settings')({
+ component: SettingsLayout,
+ /**
+ * The strings the frame renders, warmed before it renders.
+ *
+ * `ensureQueryData` rather than a prefetch, because `RouteMessages` reads them
+ * back with `useSuspenseQuery` and there is no Suspense boundary between it and
+ * the document: an unwarmed entry does not degrade here, it suspends the whole
+ * response.
+ *
+ * The session is deliberately not fetched. `_authenticated`'s `beforeLoad` has
+ * already put it in the one cache entry every guard reads, and this layout has
+ * no use for it - the frame renders nothing about the visitor.
+ */
+ loader: async ({ context }) => {
+ await context.queryClient.ensureQueryData(
+ intlQueryOptions({
+ locale: context.locale,
+ namespaces: SETTINGS_NAMESPACES,
+ }),
+ )
+ },
+ /**
+ * `noindex, nofollow` for the whole settings subtree, declared exactly once.
+ *
+ * The Next.js layout sets `robots: { index: false, follow: false }` and every
+ * page beneath it inherits that; this is the same statement in the mechanism
+ * this router has. TanStack Router merges the `head` of every matched route and
+ * dedupes `meta` by `name`, preferring the deepest occurrence - so a panel
+ * inherits this by saying nothing, and only a panel that deliberately wanted to
+ * be indexed would restate the tag. `settingsPanelHead` therefore emits a title
+ * and nothing else.
+ *
+ * Stated rather than assumed: TanStack Start emits no robots directive of its
+ * own, and these are one person's account screens.
+ */
+ head: () => ({
+ meta: [{ content: 'noindex, nofollow', name: 'robots' }],
+ }),
+ /**
+ * The trail for `/settings` itself - a single "Settings" crumb.
+ *
+ * A panel declares its own two-crumb trail and wins by being deeper
+ * (`breadcrumbOf`), and `/settings` inherits this one by declaring nothing at
+ * all. See `#/components/layout/settings-breadcrumb`.
+ */
+ staticData: { breadcrumb: },
+})
+
+function SettingsLayout() {
+ /**
+ * Where the visitor is, as the router's *internal* pathname.
+ *
+ * Internal is the whole point: the Stage 3 rewrite has already stripped the
+ * locale, so `/pl/settings/security` arrives here as `/settings/security` and
+ * the shared rules in `settings-nav.ts` compare plain paths. A rule that had to
+ * cope with a prefix would be a second copy of the locale routing.
+ *
+ * Subscribed through `useRouterState` rather than read from a match, because
+ * the nav highlight and the narrow-screen behaviour have to change on every
+ * navigation within the subtree - including the ones that do not remount this
+ * layout.
+ */
+ const pathname = useRouterState({
+ select: (state) => state.location.pathname,
+ })
+
+ return (
+
+
+ }
+ >
+
+
+
+ )
+}
diff --git a/apps/web/src/routes/_main/_authenticated/settings/devices.tsx b/apps/web/src/routes/_main/_authenticated/settings/devices.tsx
new file mode 100644
index 000000000..2baab87f0
--- /dev/null
+++ b/apps/web/src/routes/_main/_authenticated/settings/devices.tsx
@@ -0,0 +1,182 @@
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { createFileRoute } from '@tanstack/react-router'
+import { HeaderContent } from '@vitnode/core/components/ui/header-content'
+import { DevicesContent } from '@vitnode/core/views/auth/settings/devices/devices-content'
+import { DevicesListSkeleton } from '@vitnode/core/views/auth/settings/devices/devices-list-skeleton'
+import { useTranslations } from 'use-intl'
+
+import { SettingsBreadcrumb } from '#/components/layout/settings-breadcrumb'
+import { devicesQuery, useRevokeDeviceCallback } from '#/lib/devices/devices'
+import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel'
+
+/**
+ * `/settings/devices` - the devices the visitor is signed in on.
+ *
+ * The first settings panel with data of its own, and therefore the first whose
+ * loader is more than `loadSettingsPanel`. Everything around the list belongs to
+ * `settings.tsx`: the container, the ``, the navigation card, the panel card,
+ * the mobile back link, the `noindex` on the whole subtree, and the
+ * `RouteMessages` provider that puts `core.auth.settings` and `core.global` in
+ * scope. This route renders the panel *body* - which is exactly what the Next.js
+ * `DevicesSettings` renders inside `LayoutSettings`.
+ *
+ * There is deliberately no session check here. `_authenticated`'s `beforeLoad`
+ * has already answered an anonymous visitor with
+ * `/login?returnTo=/settings/devices`, and a second rule would be a second thing
+ * to keep in step rather than defence in depth. The actual boundary is neither:
+ * `GET /api/@vitnode/core/users/devices` derives the user from the session cookie
+ * on every request, which is why a session that ends while this page is open
+ * shows up below as a failed query rather than as somebody else's devices.
+ *
+ * ## One query contract, one cache entry
+ *
+ * loader: ensureQueryData(devicesQuery(userId))
+ * component: useSuspenseQuery(devicesQuery(userId))
+ * after a revoke: invalidate that one entry, and the component refetches
+ *
+ * Same key, same request, same refusal handling - so the list the server rendered
+ * is the list the browser reads. There is no `initialData`: the loader has already
+ * put it in the entry the component reads and the SSR pass dehydrates it, so a
+ * second copy of those bytes could only disagree with the first.
+ *
+ * ## Whose devices, in the cache as well as at the API
+ *
+ * The entry is keyed by the visitor - `["devices", "user", ]` - because the
+ * browser's `QueryClient` outlives a sign-out. Under the single
+ * `["devices", "me"]` it replaces, a second visitor signing in on the same
+ * document would have found that entry already populated, made no request, and
+ * been shown the first visitor's operating systems, browsers and IP addresses.
+ * Hono cannot refuse a read nobody performs.
+ *
+ * The id comes from `context.auth.user.id`, which is `_authenticated`'s own state
+ * from the one canonical session query - not a second session read. It is taken
+ * **once**, in the loader, and returned, so the loader, the component and the
+ * revoke callback all use the identical value. It addresses a cache entry and
+ * nothing else: `GET /users/devices` takes no arguments at all.
+ *
+ * ## What a revoke does *not* invalidate
+ *
+ * Anything else. The Next.js page ends its revoke with
+ * `revalidatePath('/[locale]/(main)', 'layout')`, which re-renders the whole main
+ * shell; here it is one query key. Not the session in particular, and that is a
+ * finding rather than an omission: the API answers `400` when asked to revoke the
+ * device the request itself comes from, so no revoke reachable from this page can
+ * end the session performing it. See the note on `invalidateDevices`.
+ */
+export const Route = createFileRoute('/_main/_authenticated/settings/devices')({
+ component: DevicesRoute,
+ /**
+ * The panel's strings and its list, in parallel.
+ *
+ * `loadSettingsPanel` is every settings panel's loader - it warms the settings
+ * namespaces and translates the tab title - and is awaited *alongside* the
+ * devices read rather than before it, so the two round trips overlap.
+ *
+ * A refusal from the devices API is deliberately left to propagate. `401`, `403`
+ * and `429` reject as `DevicesRequestError`, which fails this loader and shows
+ * the router's error path - the honest answer. The alternative, catching it and
+ * rendering an empty list, tells the visitor they are signed in nowhere, which
+ * is the one thing this page must never say by accident. It is also exactly what
+ * the `getDevicesApi()` this replaces did.
+ */
+ loader: async ({ context }) => {
+ /*
+ The visitor, from the guard that let this panel load. `context.auth` is
+ `_authenticated`'s `beforeLoad` return, already narrowed to the signed-in
+ half of the union, so `auth.user` needs no check here.
+ */
+ const userId = context.auth.user.id
+
+ const [panel] = await Promise.all([
+ loadSettingsPanel(context, 'devices'),
+ context.queryClient.ensureQueryData(devicesQuery(userId)),
+ ])
+
+ return { ...panel, userId }
+ },
+ /**
+ * The tab title and nothing else - `robots` is the layout's, declared once for
+ * the whole subtree.
+ *
+ * **`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 - put `head` first and `loaderData` is `never`.
+ */
+ head: ({ loaderData }) => settingsPanelHead(loaderData),
+ /**
+ * Where the Next.js page's ` }>` ends
+ * up: the same skeleton, in the same place relative to the heading.
+ *
+ * Next.js streams the heading first and fills the list in; a router shows this
+ * once a navigation into the route has been pending long enough to notice.
+ * Neither appears on a first paint - the loader has the list before anything
+ * renders - so this is the slow-client-navigation case and only that.
+ *
+ * ## Why it may translate, having mounted no provider
+ *
+ * `DevicesHeading` calls `useTranslations`, and this fallback is rendered
+ * without the panel's own component ever running - so the question is whether
+ * `settings.tsx`'s `RouteMessages` is above it by then. It always is, for one
+ * structural reason: a `pendingComponent` stands in for the *panel*, and the
+ * panel is rendered into the layout's ` ` - which exists only because
+ * the layout's own component ran, which is what mounts the provider. A pending
+ * match renders its pending element *instead of* its component, so a layout that
+ * is itself pending renders no ` ` and therefore no panel state at all.
+ * Nothing about what the layout declares enters into it.
+ *
+ * The constraint that does fall out: this must stay inside the settings subtree.
+ * A translating fallback rendered *above* that provider - a `pendingComponent`
+ * on the layout itself, say - would throw rather than degrade, and would have to
+ * mount `RouteMessages` of its own.
+ */
+ pendingComponent: DevicesPending,
+ staticData: { breadcrumb: },
+})
+
+/**
+ * The panel heading, which both states below render identically.
+ *
+ * `core.auth.settings.devices.title` and `.desc` - the panel's own ``, not
+ * the settings `` the layout renders and not the `nav.devices` label the tab
+ * title is built from.
+ */
+const DevicesHeading = () => {
+ const t = useTranslations('core.auth.settings.devices')
+
+ return
+}
+
+function DevicesPending() {
+ return (
+ <>
+
+
+ >
+ )
+}
+
+function DevicesRoute() {
+ const { userId } = Route.useLoaderData()
+ const { data } = useSuspenseQuery(devicesQuery(userId))
+ const onRevoke = useRevokeDeviceCallback(userId)
+
+ return (
+ <>
+
+
+ {/*
+ The same component the Next.js page renders, handed the two things a
+ shared list cannot resolve for itself: the devices, and the revoke.
+
+ The revoke goes straight from the browser to Hono - no server function in
+ between, because it needs no server-only secret and sets no cookie - and
+ ends in an invalidation of the one `devices/me` entry, but only when the
+ list is actually wrong. A `429` or a `401` left it exactly as it was, and
+ refetching would send the same read back into whatever refused the first.
+ That rule is core's (`shouldRefreshAfterRevoke`) and is applied by
+ `#/lib/devices/devices`, so both frameworks refresh on the same condition.
+ */}
+
+ >
+ )
+}
diff --git a/apps/web/src/routes/_main/_authenticated/settings/index.tsx b/apps/web/src/routes/_main/_authenticated/settings/index.tsx
new file mode 100644
index 000000000..542914e4a
--- /dev/null
+++ b/apps/web/src/routes/_main/_authenticated/settings/index.tsx
@@ -0,0 +1,38 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { OverviewSettings } from '@vitnode/core/views/auth/settings/overview/overview'
+
+import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel'
+
+/**
+ * `/settings` - the settings root, which renders the overview panel.
+ *
+ * **Not a redirect to `/settings/overview`**, and that is a product decision
+ * rather than a shortcut. The shell shows the navigation *instead of* the panel
+ * on a narrow screen, so a visitor who opens `/settings` on a phone is looking at
+ * a menu; redirecting them straight to `/settings/overview` would skip the menu
+ * entirely and leave the mobile back link as the only way to reach it. On a
+ * desktop the two URLs look identical, which is exactly what the Next.js app does
+ * today (`routes/main/settings/page.tsx` renders `OverviewSettings` too).
+ *
+ * So `/settings` is a real page, and the navigation marks *Overview* as current
+ * on it through the `aliases` entry in `SETTINGS_NAV_ITEMS` - one rule, shared
+ * with the Next.js app, rather than a redirect and an active-state special case
+ * that could disagree.
+ *
+ * Nothing about that can loop: this route renders, it does not navigate.
+ *
+ * `staticData` is deliberately absent, so `breadcrumbOf` falls through to the
+ * layout's single "Settings" crumb - which is what the Next.js
+ * `@breadcrumb/settings/page.tsx` slot renders for this URL.
+ */
+export const Route = createFileRoute('/_main/_authenticated/settings/')({
+ component: OverviewSettings,
+ /**
+ * `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 - put `head` first and `loaderData` is `never`. Neither
+ * error names the cause.
+ */
+ loader: async ({ context }) => await loadSettingsPanel(context, 'overview'),
+ head: ({ loaderData }) => settingsPanelHead(loaderData),
+})
diff --git a/apps/web/src/routes/_main/_authenticated/settings/overview.tsx b/apps/web/src/routes/_main/_authenticated/settings/overview.tsx
new file mode 100644
index 000000000..53c1b2303
--- /dev/null
+++ b/apps/web/src/routes/_main/_authenticated/settings/overview.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { OverviewSettings } from '@vitnode/core/views/auth/settings/overview/overview'
+
+import { SettingsBreadcrumb } from '#/components/layout/settings-breadcrumb'
+import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel'
+
+/**
+ * `/settings/overview` - the overview panel at its own URL.
+ *
+ * The same component `/settings` renders, because the root is an alias of this
+ * panel rather than a redirect to it (see `settings/index.tsx`). The two routes
+ * differ in exactly one visible way, which is the breadcrumb: this one is two
+ * crumbs deep.
+ *
+ * `OverviewSettings` is the same module the Next.js page renders and is currently
+ * a heading and nothing else. Profile editing is not a feature VitNode has yet -
+ * the route name is not a specification.
+ */
+export const Route = createFileRoute('/_main/_authenticated/settings/overview')(
+ {
+ component: OverviewSettings,
+ loader: async ({ context }) => await loadSettingsPanel(context, 'overview'),
+ head: ({ loaderData }) => settingsPanelHead(loaderData),
+ staticData: { breadcrumb: },
+ },
+)
diff --git a/apps/web/src/routes/_main/_authenticated/settings/security.tsx b/apps/web/src/routes/_main/_authenticated/settings/security.tsx
new file mode 100644
index 000000000..fbcb16609
--- /dev/null
+++ b/apps/web/src/routes/_main/_authenticated/settings/security.tsx
@@ -0,0 +1,25 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { SecuritySettings } from '@vitnode/core/views/auth/settings/security/security'
+
+import { SettingsBreadcrumb } from '#/components/layout/settings-breadcrumb'
+import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel'
+
+/**
+ * `/settings/security` - the security panel.
+ *
+ * `SecuritySettings` is the same module the Next.js page renders and is currently
+ * a heading and nothing else. Password changes, two-factor enrolment, passkeys
+ * and a session log are not features VitNode has yet, and this stage migrates
+ * what exists rather than what the URL suggests might one day live here.
+ *
+ * Anonymous, this URL answers `/login?returnTo=/settings/security` from
+ * `_authenticated`'s `beforeLoad` - no check in this file, and none wanted.
+ */
+export const Route = createFileRoute('/_main/_authenticated/settings/security')(
+ {
+ component: SecuritySettings,
+ loader: async ({ context }) => await loadSettingsPanel(context, 'security'),
+ head: ({ loaderData }) => settingsPanelHead(loaderData),
+ staticData: { breadcrumb: },
+ },
+)
diff --git a/apps/web/src/routes/_main/index.tsx b/apps/web/src/routes/_main/index.tsx
index b310ab311..d63d1d735 100644
--- a/apps/web/src/routes/_main/index.tsx
+++ b/apps/web/src/routes/_main/index.tsx
@@ -13,16 +13,20 @@ import { intlQueryOptions } from '#/lib/i18n/query'
import { vitNodeShellConfig } from '#/vitnode.shell.config'
/**
- * The Stage 3 verification page, and nothing more.
+ * The locale-runtime verification page, and nothing more.
*
- * No VitNode feature route is migrated yet - `/discover`, search, auth and the
- * AdminCP all still live in the Next.js app. What this renders is the shell and
- * the locale runtime under it: the same page at `/` and at `/pl`, one route
- * file, the language taken from the URL, `` following it, the two
- * languages' messages sitting side by side in one cache, and a switcher that
- * moves between them without a reload.
+ * What it renders is the shell and the locale runtime under it: the same page
+ * at `/` and at `/pl`, one route file, the language taken from the URL,
+ * `` following it, the two languages' messages sitting side by side
+ * in one cache, and a switcher that moves between them without a reload.
*
- * It is a scaffold. Stage 4 replaces it with the real homepage.
+ * It reads only `core.global`, from the root's provider, and mounts no
+ * `RouteMessages` of its own - which is the one thing that makes it *not* a
+ * proof that i18n works. A route's own namespaces are a separate contract, and
+ * `/discover` and `/search` are the pages that exercise it. This page passing
+ * while those failed is exactly the shape the Stage 9 i18n regression took.
+ *
+ * It is a scaffold, and the real homepage replaces it when one is designed.
*/
export const Route = createFileRoute('/_main/')({
component: Home,
@@ -75,8 +79,8 @@ function Home() {
- The VitNode application shell, rendering outside Next.js. Stage 3 is
- the locale runtime - no feature route has moved yet.
+ The VitNode application shell, rendering outside Next.js. This page is
+ the locale runtime on its own - the feature routes prove the rest.
@@ -103,9 +107,18 @@ function Home() {
-
-
- {t('loading')}
+ {/*
+ Per-key fallback, kept visible. `toggle_sidebar` is AdminCP copy, so
+ the Polish override deliberately does not carry it and this row stays
+ English while everything above it turns. That is the rule VitNode
+ relies on - a half-translated language degrades one string at a time
+ rather than rendering raw keys - and it needs a key that is not going
+ to be translated out from under it, which is why it is not one of the
+ shell strings the migrated routes render.
+ */}
+
+
+ {t('toggle_sidebar')}
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx
index 3a4bd1f61..de8c92c3e 100644
--- a/apps/web/src/routes/login.tsx
+++ b/apps/web/src/routes/login.tsx
@@ -40,16 +40,21 @@ import { vitNodeShellConfig } from '#/vitnode.shell.config'
* 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
+ * ## Where the card's two other links go
*
- * `/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.
+ * `/register` and `/login/reset-password`, and Stage 9 migrated both - which is
+ * the interesting part, because this file did not change when it happened. The
+ * links are `MigrationLink`, which asks the route tree whether this app owns a
+ * destination and otherwise falls back to a document load into the legacy app,
+ * so the day a route moves it silently becomes a client-side navigation and
+ * nothing here hardcodes a second origin or a list of what has moved.
+ *
+ * `src/tests/plugin-routes.test.ts` pins the half that is easy to get wrong in
+ * the other direction: owning `/login` must not make `/login/anything` look
+ * owned. That is why both the SSO callback and the recovery screens are
+ * *non-nested* siblings (`login_.sso.$providerId.tsx`,
+ * `login_.reset-password.tsx`) rather than children - and, for recovery, why it
+ * must not inherit this route's guest-only guard.
*/
/**
@@ -213,6 +218,14 @@ export const Route = createFileRoute('/login')({
function LoginRoute() {
const { returnTo } = Route.useSearch()
+ /**
+ * The deployment configuration. This screen deliberately ignores
+ * `config.isKnown`: a failed read degrades to no provider row and no
+ * reset-password link, and the email and password fields - which are the
+ * whole of signing in on most installs - still render. Making the login page
+ * unavailable because an optional read failed would be a far larger outage
+ * than the one that caused it.
+ */
const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions())
const signIn = useSignInAction(() => postAuthDestination(returnTo))
diff --git a/apps/web/src/routes/login_.reset-password.tsx b/apps/web/src/routes/login_.reset-password.tsx
new file mode 100644
index 000000000..c8ef55e14
--- /dev/null
+++ b/apps/web/src/routes/login_.reset-password.tsx
@@ -0,0 +1,280 @@
+import type { AbstractIntlMessages } from 'use-intl'
+
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { createFileRoute, notFound, useRouter } from '@tanstack/react-router'
+import { formatPageTitle } from '@vitnode/core/lib/metadata'
+import { ChangePasswordFormContent } from '@vitnode/core/views/auth/password-reset/change-password-form/change-password-form-content'
+import { PasswordResetFormContent } from '@vitnode/core/views/auth/password-reset/form/password-reset-form-content'
+import { PasswordResetContent } from '@vitnode/core/views/auth/password-reset/password-reset-content'
+import { ErrorContent } from '@vitnode/core/views/error/error-content'
+import { createTranslator, useTranslations } from 'use-intl'
+
+import { ErrorActions } from '#/components/error-actions'
+import { RouteMessages } from '#/components/route-messages'
+import {
+ changePasswordFromResetAction,
+ requestPasswordResetAction,
+} from '#/lib/auth/actions'
+import {
+ normalizePasswordResetSearch,
+ passwordRecoveryAvailability,
+ PasswordRecoveryUnknownError,
+ passwordResetMode,
+ passwordResetNamespaces,
+} from '#/lib/auth/password-reset-route'
+import { LOGIN_PATH, parseInternalDestination } from '#/lib/auth/redirects'
+import { intlQueryOptions } from '#/lib/i18n/query'
+import { middlewareConfigQueryOptions } from '#/lib/middleware-config'
+import { vitNodeShellConfig } from '#/vitnode.shell.config'
+
+/**
+ * Password recovery, rendered outside Next.js - both halves of it.
+ *
+ * One route file serving `/login/reset-password` and
+ * `/pl/login/reset-password`, and within each, two screens chosen from the
+ * query:
+ *
+ * /login/reset-password ask for a link
+ * /login/reset-password?token=..&userId=.. choose a new password
+ *
+ * which is what the Next.js `PasswordResetView` does with `if (token && userId)`.
+ * That route is still live and unchanged; this is a parallel slice until the
+ * cutover.
+ *
+ * ## Why it is a sibling of `/login` rather than a child
+ *
+ * The file is `login_.reset-password.tsx` - the trailing underscore opts out of
+ * nesting - and the reason is the same one that keeps the SSO callback out from
+ * under `/login`, only sharper here.
+ *
+ * **`/login`'s guard must not run on this page.** `/login` is guest-only; this
+ * is not, and must not be. A recovery link is followed out of an email, on
+ * whatever device happens to be to hand, and a visitor who is already signed in
+ * somewhere else has every right to finish setting a new password - the Next.js
+ * view has never checked a session, and neither does this. Nested under `/login`
+ * the guest guard would redirect them away mid-flow, burning a one-shot token.
+ *
+ * The second reason still holds too: `/login` must stay an exact match so
+ * `isTanStackOwnedPath` decides ownership at each leaf rather than by prefix.
+ * Two leaves, no shared parent - `src/tests/auth-routes.test.ts` pins it.
+ *
+ * ## What is shared
+ *
+ * Everything visible. `PasswordResetContent`, `PasswordResetFormContent` and
+ * `ChangePasswordFormContent` are the same modules the Next.js view renders,
+ * handed the three things a shared component cannot resolve for itself: the
+ * captcha configuration, the two mutations, and where to go once the password
+ * has changed.
+ */
+
+/**
+ * Where the visitor goes once the password has changed.
+ *
+ * The login page, replacing the current entry rather than pushing one - which is
+ * what the Next.js form does (`replace("/login")`) and worth keeping for a
+ * reason beyond parity: the URL being left behind carries a recovery token, and
+ * a push would leave it one Back press away.
+ *
+ * The API mints **no session** on a password change, so this really is the next
+ * step rather than a redundant hop: the visitor is still signed out.
+ *
+ * `parseInternalDestination` rather than a bare `to`, so the navigation goes
+ * through `buildLocation` and the rewrite writes the locale prefix back - a
+ * Polish visitor lands on `/pl/login`.
+ */
+const CHANGED_PASSWORD_DESTINATION = {
+ ...parseInternalDestination(LOGIN_PATH),
+ replace: true,
+}
+
+/**
+ * The page's own title, translated once, in the request's language.
+ *
+ * `core.auth.reset_password.title` in **both** modes, which is what the Next.js
+ * route's `generateMetadata` produces - it is page-level there and cannot vary
+ * by mode. That is why `core.auth.reset_password` is in the base namespace set;
+ * see `passwordResetNamespaces`.
+ *
+ * The cast is what makes `createTranslator` usable here; see the note on
+ * `translateTitle` in `routes/login.tsx`.
+ */
+const translateTitle = (locale: string, messages: AbstractIntlMessages) =>
+ createTranslator({
+ locale,
+ messages: messages as {
+ core: { auth: { reset_password: { title: string } } }
+ },
+ namespace: 'core.auth.reset_password',
+ })('title')
+
+export const Route = createFileRoute('/login_/reset-password')({
+ validateSearch: normalizePasswordResetSearch,
+ /**
+ * Password recovery only exists on a deployment that can send email - and
+ * "we could not find out" is a third answer, not a fourth spelling of no.
+ *
+ * The API mails the reset link through the configured email adapter, so with
+ * no adapter the form's submit could never arrive, which is why the Next.js
+ * view answers `notFound()` rather than rendering it. Preserved exactly, in
+ * this framework's own vocabulary: `notFound()` from TanStack Router rather
+ * than `next/navigation`'s.
+ *
+ * What is *not* preserved is answering the same way when the configuration
+ * could not be read. The fallback the config query degrades to says
+ * `isEmail: false` - correct for the login form, which still renders its email
+ * and password fields - and reading that as a boolean here turned an API
+ * outage into a **404**: this application asserting the page does not exist
+ * because it could not reach its own API, to a visitor holding a valid
+ * recovery link. `passwordRecoveryAvailability` separates the two, and the
+ * outage takes the router's ordinary error path instead.
+ *
+ * ## The status is decided before anything renders
+ *
+ * That is the whole reason this sits in `beforeLoad`, and it is the same
+ * argument the Next.js route makes for its `instant = false`: the response
+ * status depends on a read only the API can answer, and a page that committed
+ * a 200 and then discovered it had nothing to show would leave crawlers,
+ * caches and monitoring with a successful reset-password page. Thrown here,
+ * the router's server pass resolves the boundary before the stream opens and
+ * answers **404** for a genuinely disabled deployment (`applyFailure` in
+ * `@tanstack/router-core`), so the status is right without this route setting
+ * one by hand - and an outage never reaches that path at all.
+ */
+ beforeLoad: async ({ context }) => {
+ const config = await context.queryClient.ensureQueryData(
+ middlewareConfigQueryOptions(),
+ )
+
+ const availability = passwordRecoveryAvailability(config)
+
+ // Not a 404: the route exists, the API could not say whether the flow does.
+ if (availability === 'unknown') throw new PasswordRecoveryUnknownError()
+
+ // TanStack Router's own control-flow signal, like `redirect()`.
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ if (availability === 'disabled') throw notFound()
+ },
+ /**
+ * The loader re-runs when the *mode* changes, and only then.
+ *
+ * Without this it would warm the namespaces for whichever screen the page was
+ * first opened with and never again, so following a fresh recovery link from
+ * an already-open request form would mount a provider for a set nobody
+ * fetched - which suspends the whole response rather than degrading.
+ *
+ * The mode rather than the raw parameters, because that is what the read
+ * actually depends on: a different token is the same screen.
+ */
+ loaderDeps: ({ search }) => ({ mode: passwordResetMode(search).mode }),
+ /**
+ * The strings this mode renders, warmed before it renders.
+ *
+ * `namespaces` is returned rather than recomputed in the component so the set
+ * mounted is *literally* the set warmed - the list is part of the query key,
+ * and two derivations that drifted would suspend the page.
+ *
+ * The deployment configuration is not fetched again: `beforeLoad` has already
+ * put it in the cache entry the component reads back.
+ */
+ loader: async ({ context, deps }) => {
+ const namespaces = passwordResetNamespaces(deps.mode)
+ const intl = await context.queryClient.ensureQueryData(
+ intlQueryOptions({ locale: context.locale, namespaces }),
+ )
+
+ return { namespaces, title: translateTitle(context.locale, intl.messages) }
+ },
+ /**
+ * The tab title. **`head` must be written after `loader`** - see the note in
+ * `routes/register.tsx`.
+ */
+ head: ({ loaderData }) => ({
+ meta: loaderData
+ ? [
+ {
+ title: formatPageTitle(
+ vitNodeShellConfig.metadata,
+ loaderData.title,
+ ),
+ },
+ ]
+ : [],
+ }),
+ /**
+ * The 404 for an install with no email adapter.
+ *
+ * Core's shared error screen, with this framework's navigation in its `actions`
+ * slot - the same pair the SSO callback renders, which is why the buttons live
+ * in `#/components/error-actions` rather than in either route.
+ *
+ * `core.global` comes from the root route, so this translates without a
+ * `RouteMessages` above it - which it has to, because a `notFoundComponent`
+ * renders *instead of* the component that would have mounted one. This app has
+ * no global not-found screen yet; when it grows one, this route can drop its
+ * own.
+ */
+ notFoundComponent: PasswordRecoveryUnavailable,
+ component: PasswordResetRoute,
+})
+
+function PasswordRecoveryUnavailable() {
+ const t = useTranslations('core.global')
+
+ return (
+
+ }
+ code={404}
+ description={t('errors.404.desc')}
+ title={t('errors.404.title')}
+ />
+
+ )
+}
+
+function PasswordResetRoute() {
+ const { namespaces } = Route.useLoaderData()
+ const search = Route.useSearch()
+ const router = useRouter()
+ const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions())
+
+ /**
+ * Which screen, decided from the same pure function the loader used - so the
+ * namespaces mounted below are the ones warmed for this mode.
+ *
+ * The change-password branch carries the *parsed* link, which is what makes it
+ * impossible to render that form without both halves of a well-formed one.
+ */
+ const mode = passwordResetMode(search)
+
+ return (
+
+
+
+ {mode.mode === 'change' ? (
+ {
+ void router.navigate(CHANGED_PASSWORD_DESTINATION)
+ }}
+ onChangePassword={changePasswordFromResetAction}
+ />
+ ) : (
+ /*
+ No `onSuccess` and no navigation: an accepted request swaps the
+ card for "check your email" and leaves the visitor there, which is
+ what the Next.js form does. It says the same thing for an address
+ with an account and one without, because the API answers the same
+ 201 for both - the anti-enumeration behaviour is preserved by there
+ being nothing here that could distinguish them.
+ */
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/src/routes/login_.sso.$providerId.tsx b/apps/web/src/routes/login_.sso.$providerId.tsx
index fd752a22a..57f033cc6 100644
--- a/apps/web/src/routes/login_.sso.$providerId.tsx
+++ b/apps/web/src/routes/login_.sso.$providerId.tsx
@@ -1,13 +1,10 @@
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 { ErrorActions } from '#/components/error-actions'
import { MigrationLink } from '#/components/migration-link'
import { RouteMessages } from '#/components/route-messages'
import { useCompleteSsoAction } from '#/lib/auth/actions'
@@ -43,10 +40,13 @@ import {
* 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.
+ * 2. **`/login` must stay an exact match.** A `/login` route with children
+ * matches every path beneath it, so `isTanStackOwnedPath` would answer
+ * "owned" for URLs no route declares and hand a page the Next.js app still
+ * serves to this router as a client-side navigation it cannot render. Stage 9
+ * added a third leaf for the same reason - `/login/reset-password` is a
+ * sibling too, and must be, because it is *not* guest-only. Three 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
@@ -97,42 +97,6 @@ export const Route = createFileRoute('/login_/sso/$providerId')({
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()
@@ -180,7 +144,7 @@ function SsoCallbackRoute() {
}
+ errorActions={ }
LinkComponent={MigrationLink}
providerId={providerId}
providers={ssoProvidersOf(config)}
diff --git a/apps/web/src/routes/register.tsx b/apps/web/src/routes/register.tsx
new file mode 100644
index 000000000..447751952
--- /dev/null
+++ b/apps/web/src/routes/register.tsx
@@ -0,0 +1,248 @@
+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 { SignUpFormContent } from '@vitnode/core/views/auth/sign-up/form/sign-up-form-content'
+import { SignUpContent } from '@vitnode/core/views/auth/sign-up/sign-up-content'
+import { SSOButtonsContent } from '@vitnode/core/views/auth/sso/buttons/sso-buttons-content'
+import { createTranslator } from 'use-intl'
+
+import { MigrationLink } from '#/components/migration-link'
+import { RouteMessages } from '#/components/route-messages'
+import { startSsoAction, useSignUpAction } from '#/lib/auth/actions'
+import { ensureAuthState } from '#/lib/auth/query'
+import {
+ parseInternalDestination,
+ 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 { vitNodeShellConfig } from '#/vitnode.shell.config'
+
+/**
+ * The registration page, rendered outside Next.js.
+ *
+ * One route file serving `/register` and `/pl/register`: 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/register.tsx` to keep in step. The Next.js route at
+ * `packages/vitnode/src/routes/main/register/page.tsx` is still live and
+ * unchanged - this is a parallel slice until the cutover.
+ *
+ * ## Where it sits
+ *
+ * A direct child of the root, alongside `/login` and the SSO callback, and
+ * deliberately **not** under `_main`. That is Stage 8's decision rather than this
+ * stage's: the auth screens are full-height blank pages that own their own
+ * measure and their own ``, and mounting the site header above a signup
+ * card would be a product change nobody asked for. `src/tests/main-shell.test.ts`
+ * pins that this file renders exactly one ``, because with no shell above
+ * it a page without one is a document with no main landmark at all.
+ *
+ * ## What is shared
+ *
+ * Everything visible. `SignUpContent`, `SignUpFormContent` 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
+ * register, and a way to start an SSO flow. The email-confirmation screen comes
+ * with `SignUpContent` - it mounts `WrapperSignUp` itself - so there is nothing
+ * to wire here for the unverified branch.
+ */
+
+/**
+ * What this page renders strings from.
+ *
+ * `core.global` is the heading's and the error toasts', `core.auth.sign_up` is
+ * the form's, `core.auth.sso` is the provider row's - the same three the Next.js
+ * view declares. 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 REGISTER_NAMESPACES = [
+ 'core.global',
+ 'core.auth.sign_up',
+ 'core.auth.sso',
+] as const
+
+/**
+ * The page's own title, translated once, in the request's language.
+ *
+ * `core.global.register` - the same key the Next.js route's `generateMetadata`
+ * reads. The cast is what makes `createTranslator` usable here at all; see the
+ * long note on `translateTitle` in `routes/login.tsx`, which has the identical
+ * shape for the identical reason.
+ */
+const translateTitle = (locale: string, messages: AbstractIntlMessages) =>
+ createTranslator({
+ locale,
+ messages: messages as { core: { global: { register: string } } },
+ namespace: 'core.global',
+ })('register')
+
+export const Route = createFileRoute('/register')({
+ /**
+ * Guest-only, decided before anything renders - the same rule `/login`
+ * applies, through the same predicate.
+ *
+ * There is no second guard implementation here and there must not be:
+ * `canAccessGuestRoute` is the inverse of the rule `_authenticated` enforces,
+ * so "signed in" cannot come to mean two different things on two pages.
+ *
+ * ## Where a signed-in visitor goes
+ *
+ * The front page, and only the front page. This route takes **no `returnTo`**,
+ * because nothing sends one: the login card's "create an account" link is a
+ * bare `/register` in both frameworks, and inventing a parameter here would be
+ * a behaviour the Next.js page does not have. `postAuthDestination(undefined)`
+ * is the same helper `/login` and the SSO callback use to say "wherever a
+ * finished sign-in lands with nothing asked for", so the answer stays in one
+ * place.
+ *
+ * `parseInternalDestination` rather than `href`, so the redirect goes through
+ * `buildLocation` and the locale rewrite writes the prefix back - a Polish
+ * visitor is sent to `/pl`, not to `/`.
+ *
+ * ## 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.
+ */
+ beforeLoad: async ({ context }) => {
+ const auth = await ensureAuthState(context.queryClient)
+
+ if (!canAccessGuestRoute(auth)) {
+ // TanStack Router's own control-flow signal - see the note in
+ // `routes/_main/_authenticated.tsx`.
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ throw redirect(parseInternalDestination(postAuthDestination(undefined)))
+ }
+ },
+ /**
+ * 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 deployment
+ * configuration by `useSuspenseQuery` through the identical
+ * `middlewareConfigQueryOptions` - the same entry `/login` warms, so arriving
+ * from the login card costs nothing.
+ *
+ * The session is *not* fetched. `beforeLoad` has already put it in the cache
+ * entry every guard reads.
+ */
+ loader: async ({ context }) => {
+ const [intl] = await Promise.all([
+ context.queryClient.ensureQueryData(
+ intlQueryOptions({
+ locale: context.locale,
+ namespaces: REGISTER_NAMESPACES,
+ }),
+ ),
+ context.queryClient.ensureQueryData(middlewareConfigQueryOptions()),
+ ])
+
+ return { title: translateTitle(context.locale, intl.messages) }
+ },
+ /**
+ * The tab title, in the language the request resolved to.
+ *
+ * **`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: RegisterRoute,
+})
+
+function RegisterRoute() {
+ /**
+ * The deployment configuration, and a deliberate decision not to branch on
+ * whether it was actually read.
+ *
+ * `config.isKnown` is available here - the same certainty flag password
+ * recovery acts on - and registration keeps its degraded rendering anyway: on
+ * an outage the card still shows the fields, minus the captcha widget and the
+ * provider row. That is a real cost on a captcha-configured deployment, where
+ * the submit then carries an empty token and the API answers `400`, which the
+ * form raises as the internal-error toast. Degraded, but never wrong: nothing
+ * is created and nothing is claimed to have been.
+ *
+ * It stays that way because the alternative is worse for the same visitor. A
+ * hard error would take registration down for every deployment - captcha or
+ * not - because one optional read failed, and most VitNode installs configure
+ * no captcha at all, so their signup would work perfectly if only it rendered.
+ * Password recovery is different in kind rather than in degree: there the
+ * fallback does not degrade a screen, it *asserts a fact* - "this deployment
+ * sends no email" - and turns that into a 404.
+ */
+ const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions())
+
+ /**
+ * Registering, and what happens on the two kinds of success.
+ *
+ * The action is Agent A's, and it owns the ordering that matters: on a
+ * deployment with no email adapter the API marks the account verified and
+ * mints a session on the same response, so the cookie is copied onto this
+ * response, the canonical session entry is invalidated, and only then does the
+ * router move - a navigation that ran first would arrive at a guard still
+ * holding the anonymous session.
+ *
+ * On a deployment *with* an email adapter the account is unverified and no
+ * session exists, so the action navigates nowhere and answers
+ * `{ emailConfirmation }`; the shared form hands that to `WrapperSignUp` and
+ * the card is replaced by the "check your email" screen. Nothing here pretends
+ * the visitor is signed in.
+ *
+ * The destination is a thunk because `useSignInAction` takes one - there is no
+ * `returnTo` on this route to read late, so it is a constant, and it is the
+ * same `postAuthDestination(undefined)` the guard above sends a signed-in
+ * visitor to. Routed through `useMigrationNavigate` inside the action, so a
+ * front page this app did not own would still be reached.
+ */
+ const signUp = useSignUpAction(() => postAuthDestination(undefined))
+
+ return (
+
+
+
+ }
+ LinkComponent={MigrationLink}
+ sso={
+
+ }
+ />
+
+
+ )
+}
diff --git a/apps/web/src/server/auth.server.ts b/apps/web/src/server/auth.server.ts
index 5f71cd134..22662b9bb 100644
--- a/apps/web/src/server/auth.server.ts
+++ b/apps/web/src/server/auth.server.ts
@@ -2,23 +2,33 @@ 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 { CAPTCHA_TOKEN_HEADER } from '@vitnode/core/lib/fetcher/request-context'
import type {
+ ChangePasswordInput,
+ ChangePasswordResult,
CompleteSsoResult,
+ PasswordResetRequestInput,
+ PasswordResetRequestResult,
SignInInput,
SignInResult,
SignOutInput,
SignOutResult,
+ SignUpInput,
+ SignUpResult,
SsoCallbackInput,
SsoStartInput,
SsoStartResult,
} from '#/lib/auth/contract'
import {
+ changePasswordResultFromStatus,
completeSsoResultFromStatus,
+ passwordResetRequestResultFromStatus,
shouldSaveApiCookies,
signInResultFromStatus,
signOutResultFromStatus,
+ signUpResultFromStatus,
ssoStartResultFromStatus,
} from '#/lib/auth/contract'
import { fetcherServer, saveApiCookies } from '#/server/fetcher.server'
@@ -77,6 +87,37 @@ const callUsersApi = async (
}
}
+/**
+ * A reply's body, as JSON, or `undefined`.
+ *
+ * A body that is not JSON - an HTML error page from something in front of the
+ * API, an empty response - makes `json()` throw, and an exception escaping a
+ * server function is serialized back to the browser. `undefined` fails the
+ * `201` schema instead, which the caller already reads as a `server_error`.
+ */
+const readJson = async (response: Response): Promise => {
+ try {
+ return await response.json()
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * A reply's body as text, or `""`.
+ *
+ * Only ever handed to core's `signUpConflictReason`, which classifies it and
+ * throws it away; `""` classifies as `"unknown"`, which is the right answer for a
+ * conflict nobody could read.
+ */
+const readText = async (response: Response): Promise => {
+ try {
+ return await response.text()
+ } catch {
+ return ''
+ }
+}
+
/**
* Copies the session, device and SSO-state cookies the API just minted onto this
* app's response.
@@ -207,3 +248,126 @@ export const completeSsoOnApi = async (
return completeSsoResultFromStatus(response.status)
}
+
+/**
+ * The captcha header, or nothing at all.
+ *
+ * `useCaptcha` reports itself ready with an empty token when this deployment has
+ * no captcha configured, and the API's `captchaMiddleware` short-circuits in
+ * exactly that case - but only if the header is *absent*. An empty
+ * `x-vitnode-captcha-token` is a present header with no token, which a configured
+ * deployment would reject as `400 "Captcha token is required"`. So the absence is
+ * the meaningful part, which is why this is a spread and not a value.
+ *
+ * The header name comes from `@vitnode/core`, where the middleware reads it, so
+ * this app never spells it out.
+ */
+const captchaHeaders = (captchaToken: string): Record =>
+ captchaToken ? { [CAPTCHA_TOKEN_HEADER]: captchaToken } : {}
+
+/**
+ * Registers a new account.
+ *
+ * The one mutation here whose success may or may not be a session, and the reason
+ * the cookies are copied before the status is looked at. On a deployment with no
+ * email adapter the API marks the account verified and calls
+ * `createSessionByUserId` on the same request, so the `201` carries a
+ * `Set-Cookie` this server has to forward - lose it and the visitor is registered
+ * and immediately anonymous. On a deployment *with* one, the same `201` carries
+ * no session and `emailVerified: false` says so.
+ *
+ * The two bodies that are read are read for different reasons: the `201` because
+ * the caller needs the address and the flag, and the `409` because the API puts
+ * the conflicting field's name in its text. Neither string is forwarded - the
+ * `409` is classified by core's own `signUpConflictReason` and the `201` is parsed
+ * by a schema.
+ */
+export const signUpOnApi = async ({
+ captchaToken,
+ ...body
+}: SignUpInput): Promise => {
+ const response = await callUsersApi(async () =>
+ fetcherServer(users, {
+ additionalHeaders: captchaHeaders(captchaToken),
+ args: { body },
+ method: 'post',
+ module: 'users',
+ path: '/sign_up',
+ }),
+ )
+
+ if (!response) return { ok: false, reason: 'server_error' }
+
+ saveCookiesFrom(response)
+
+ if (response.status === 201) {
+ return signUpResultFromStatus(201, { body: await readJson(response) })
+ }
+
+ if (response.status === 409) {
+ return signUpResultFromStatus(409, { conflict: await readText(response) })
+ }
+
+ return signUpResultFromStatus(response.status)
+}
+
+/**
+ * Asks the API to email a password-reset link.
+ *
+ * No cookies to copy: this route mints nothing, and `saveApiCookies` writes every
+ * cookie a response carries, so it is not called for a response that has no
+ * business setting one.
+ *
+ * Nothing is read off the reply either, and nothing could be: the API answers
+ * `201` whether or not the address belongs to an account. Preserving that
+ * silence is the point - see `passwordResetRequestResultFromStatus`.
+ */
+export const requestPasswordResetOnApi = async ({
+ captchaToken,
+ email,
+}: PasswordResetRequestInput): Promise => {
+ const response = await callUsersApi(async () =>
+ fetcherServer(users, {
+ additionalHeaders: captchaHeaders(captchaToken),
+ args: { body: { email } },
+ method: 'post',
+ module: 'users',
+ path: '/reset-password',
+ }),
+ )
+
+ if (!response) return { ok: false, reason: 'server_error' }
+
+ return passwordResetRequestResultFromStatus(response.status)
+}
+
+/**
+ * Sets a new password from a recovery link.
+ *
+ * The API does all of the security-relevant work and keeps doing it: it looks the
+ * recovery row up by `userId` *and* `token` *and* an unexpired `expiresAt`,
+ * rejects the request when any of the three does not match, hashes the new
+ * password and deletes the row. This layer validates the shape of the three
+ * values and maps the status.
+ *
+ * **No session is minted and none is copied.** The route answers `201` with no
+ * `Set-Cookie`, so a visitor who has just changed their password is still signed
+ * out - which is why this is the one mutation here that does not go anywhere near
+ * `saveCookiesFrom`, and why a caller must not refresh a session around it.
+ */
+export const changePasswordFromResetOnApi = async (
+ data: ChangePasswordInput,
+): Promise => {
+ const response = await callUsersApi(async () =>
+ fetcherServer(users, {
+ args: { body: data },
+ method: 'post',
+ module: 'users',
+ path: '/change-password',
+ }),
+ )
+
+ if (!response) return { ok: false, reason: 'server_error' }
+
+ return changePasswordResultFromStatus(response.status)
+}
diff --git a/apps/web/src/server/devices.server.ts b/apps/web/src/server/devices.server.ts
new file mode 100644
index 000000000..43ff2b201
--- /dev/null
+++ b/apps/web/src/server/devices.server.ts
@@ -0,0 +1,45 @@
+import '@tanstack/react-start/server-only'
+import type { DevicesFetcher } from '@vitnode/core/views/auth/settings/devices/devices-query'
+
+import {
+ devicesRequest,
+ DevicesRequestError,
+ usersModuleRef,
+} from '@vitnode/core/views/auth/settings/devices/devices-query'
+
+import { fetcherServer } from '#/server/fetcher.server'
+
+/**
+ * The visitor's devices, fetched during SSR.
+ *
+ * The request and the refusal check are core's - the same two the browser fetcher
+ * uses - so a list rendered on the server and a list refetched after a revoke are
+ * the same request with the same failure semantics. Only the *transport* is this
+ * module's, and it is the only part that genuinely cannot be shared.
+ *
+ * `fetcherServer` rather than a bare `fetch`, and here it carries two things
+ * rather than one:
+ *
+ * - **The session cookie**, which is whose devices these are. A render that
+ * forwarded nothing would be answered as an anonymous visitor - `401` - so this
+ * is the difference between a signed-in page and an error.
+ * - **The device cookie**, which is which row is `isCurrent`. The API compares
+ * each row's `publicId` to it, so a render that dropped it would mark every row
+ * revokable and offer to sign the reader out of the session they are reading
+ * with. `buildForwardedHeaders` sends the whole `Cookie` header, so both travel
+ * together.
+ *
+ * It also resolves the API origin from the request being rendered, so a preview
+ * deployment calls its own hostname rather than a configured one.
+ *
+ * Only ever reached through the isomorphic transport in `#/lib/devices/devices`,
+ * which is what keeps this module - and the `server-only` marker above it - out
+ * of the browser bundle.
+ */
+export const fetchDevicesOnServer: DevicesFetcher = async () => {
+ const response = await fetcherServer(usersModuleRef, devicesRequest())
+
+ if (!response.ok) throw new DevicesRequestError(response.status)
+
+ return await response.json()
+}
diff --git a/apps/web/src/server/middleware-config.server.ts b/apps/web/src/server/middleware-config.server.ts
index f52cb5175..bc251ad1d 100644
--- a/apps/web/src/server/middleware-config.server.ts
+++ b/apps/web/src/server/middleware-config.server.ts
@@ -3,9 +3,12 @@ import type { middlewareModule } from '@vitnode/core/api/modules/middleware/midd
import { clientModule } from '@vitnode/core/lib/fetcher-client'
-import type { MiddlewareConfig } from '#/lib/middleware-config'
+import type { MiddlewareConfigState } from '#/lib/middleware-config'
-import { ANONYMOUS_MIDDLEWARE_CONFIG } from '#/lib/middleware-config'
+import {
+ knownMiddlewareConfig,
+ UNKNOWN_MIDDLEWARE_CONFIG,
+} from '#/lib/middleware-config'
import { fetcherServer } from '#/server/fetcher.server'
/**
@@ -24,7 +27,7 @@ import { fetcherServer } from '#/server/fetcher.server'
const middleware = clientModule('@vitnode/core')
export const fetchMiddlewareConfigOnServer =
- async (): Promise => {
+ async (): Promise => {
try {
const response = await fetcherServer(middleware, {
method: 'get',
@@ -32,18 +35,23 @@ export const fetchMiddlewareConfigOnServer =
path: '/',
})
- if (response.status !== 200) return ANONYMOUS_MIDDLEWARE_CONFIG
+ if (response.status !== 200) return UNKNOWN_MIDDLEWARE_CONFIG
- return await response.json()
+ return knownMiddlewareConfig(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.
+ //
+ // The fallback carries `isKnown: false`, which is what stops that
+ // degradation from spreading to the screens it would be wrong for -
+ // password recovery must not read an outage as "this deployment sends no
+ // email" and answer 404.
// eslint-disable-next-line no-console
console.error('[auth] middleware configuration unavailable', error)
- return ANONYMOUS_MIDDLEWARE_CONFIG
+ return UNKNOWN_MIDDLEWARE_CONFIG
}
}
diff --git a/apps/web/src/tests/auth-routes.test.ts b/apps/web/src/tests/auth-routes.test.ts
new file mode 100644
index 000000000..aa3b3bddf
--- /dev/null
+++ b/apps/web/src/tests/auth-routes.test.ts
@@ -0,0 +1,323 @@
+import {
+ defaultParseSearch,
+ defaultStringifySearch,
+} from '@tanstack/react-router'
+import { describe, expect, it } from 'vitest'
+
+import {
+ normalizePasswordResetSearch,
+ passwordRecoveryAvailability,
+ passwordResetMode,
+ passwordResetNamespaces,
+} from '#/lib/auth/password-reset-route'
+import { postAuthDestination } from '#/lib/auth/redirects'
+import {
+ knownMiddlewareConfig,
+ UNKNOWN_MIDDLEWARE_CONFIG,
+} from '#/lib/middleware-config'
+import { getRouter } from '#/router'
+
+/**
+ * The two auth routes migrated in Stage 9, as data.
+ *
+ * Everything here is either a pure function over a URL's query or a question put
+ * to the route tree. Nothing renders and nothing is fetched: whether the
+ * registration card produces the right markup is `@vitnode/core`'s business, and
+ * whether the mutations reach Hono is covered by typecheck and the build.
+ */
+
+/** What the API actually puts in a recovery email: 32 random bytes, base64url. */
+const TOKEN = 'PSyRy0nQ0hRnfx3iCYldQ40mBLU9lqfDWtvNhrTsJI4'
+
+describe('the reset-password search schema', () => {
+ it('keeps a well-formed recovery link', () => {
+ // `?userId=123` reaches `validateSearch` as a *number*: TanStack parses each
+ // value with `JSON.parse`.
+ expect(normalizePasswordResetSearch({ token: TOKEN, userId: 123 })).toEqual(
+ { token: TOKEN, userId: 123 },
+ )
+ })
+
+ it('never coerces the account id, so the URL round-trips', () => {
+ // The one thing this schema must not do. The default stringifier is
+ // `JSON.parse`'s inverse, so the string '123' serialises back as
+ // `?userId=%22123%22` - a different location than the one that arrived, which
+ // the server's canonical-href check answers with a 307. Both spellings are
+ // therefore returned exactly as they came in.
+ expect(normalizePasswordResetSearch({ userId: 123 }).userId).toBe(123)
+ expect(normalizePasswordResetSearch({ userId: '123' }).userId).toBe('123')
+ })
+
+ it.each([
+ ['an empty token', { token: '' }],
+ ['a numeric token', { token: 123 }],
+ ['a boolean token', { token: true }],
+ ['a listed token', { token: [TOKEN] }],
+ ['a null token', { token: null }],
+ ])('drops %s rather than carrying it', (_case, input) => {
+ expect(normalizePasswordResetSearch(input)).not.toHaveProperty('token')
+ })
+
+ it.each([
+ ['an empty account id', { userId: '' }],
+ ['a boolean account id', { userId: true }],
+ ['a listed account id', { userId: ['1', '2'] }],
+ ['a null account id', { userId: null }],
+ ['an object account id', { userId: {} }],
+ ])('drops %s rather than carrying it', (_case, input) => {
+ expect(normalizePasswordResetSearch(input)).not.toHaveProperty('userId')
+ })
+
+ it('answers an empty object for a bare URL, so nothing is written back', () => {
+ expect(normalizePasswordResetSearch({})).toEqual({})
+ })
+
+ it('ignores parameters it does not own', () => {
+ expect(
+ normalizePasswordResetSearch({ returnTo: '/x', token: TOKEN, userId: 1 }),
+ ).toEqual({ token: TOKEN, userId: 1 })
+ })
+})
+
+describe('a recovery URL survives the router serialising it back', () => {
+ /**
+ * The canonical-location check, exercised against the router's own default
+ * search serialisers rather than described in prose.
+ *
+ * `loadServerRoute` rebuilds the location from the validated search and
+ * redirects when the result differs from the URL that arrived. So the schema's
+ * output has to stringify back to exactly the query it was parsed from - and
+ * this is the pair of functions that decides that, `JSON.parse` per value one
+ * way and its inverse the other.
+ */
+ it.each([
+ // The ordinary link.
+ `?token=${TOKEN}&userId=123`,
+ // A token starting with a digit, and one starting with `-`. Both trip the
+ // stringifier's "does this look like JSON?" test and both fall through to
+ // being returned verbatim, because neither actually parses.
+ `?token=7${TOKEN.slice(1)}&userId=1`,
+ `?token=-${TOKEN.slice(1)}&userId=1`,
+ // The bare request form.
+ '',
+ ])('rebuilds %s unchanged', (search) => {
+ const validated = normalizePasswordResetSearch(defaultParseSearch(search))
+
+ expect(defaultStringifySearch(validated)).toBe(search)
+ })
+
+ it('would not, if the account id were coerced to a string', () => {
+ // The control, and the reason `PasswordResetSearch.userId` is `number |
+ // string`: a schema that normalised `123` to `'123'` would send every
+ // recovery link through a 307 to a quoted URL.
+ expect(defaultStringifySearch({ userId: 123 })).toBe('?userId=123')
+ expect(defaultStringifySearch({ userId: '123' })).toBe('?userId=%22123%22')
+ })
+})
+
+describe('which recovery screen a URL asks for', () => {
+ it('reads a complete link as the change-password screen, carrying it parsed', () => {
+ expect(passwordResetMode({ token: TOKEN, userId: 123 })).toEqual({
+ link: { token: TOKEN, userId: 123 },
+ mode: 'change',
+ })
+ })
+
+ it('normalises a string account id into the number the API wants', () => {
+ const mode = passwordResetMode({ token: TOKEN, userId: '123' })
+
+ expect(mode.mode).toBe('change')
+ expect(mode.mode === 'change' && mode.link.userId).toBe(123)
+ })
+
+ it.each([
+ ['nothing at all', {}],
+ ['a token with no account', { token: TOKEN }],
+ ['an account with no token', { userId: 123 }],
+ ])('falls back to the request screen for %s', (_case, search) => {
+ // "Do not pass partially present credentials to the API", stated as a test:
+ // there is no shape in which half a link reaches the change-password form.
+ expect(passwordResetMode(search)).toEqual({ mode: 'request' })
+ })
+
+ it.each([
+ ['a zero account id', { token: TOKEN, userId: 0 }],
+ ['a negative account id', { token: TOKEN, userId: -1 }],
+ ['a fractional account id', { token: TOKEN, userId: 1.5 }],
+ ['a token too short to be one', { token: 'abc', userId: 1 }],
+ ['a token with a path separator', { token: `../${TOKEN}`, userId: 1 }],
+ ['an unbounded token', { token: 'a'.repeat(513), userId: 1 }],
+ ])('falls back to the request screen for %s', (_case, search) => {
+ expect(passwordResetMode(search)).toEqual({ mode: 'request' })
+ })
+})
+
+describe('the namespaces each recovery screen needs', () => {
+ it.each(['change', 'request'] as const)(
+ 'always includes the root-provider set and the title namespace in %s mode',
+ (mode) => {
+ // `RouteMessages` replaces the root's provider, so `core.global` has to be
+ // in every set or the error toasts render their keys. The title comes from
+ // `core.auth.reset_password` in *both* modes, which is what the Next.js
+ // route's page-level `generateMetadata` produces.
+ const namespaces = passwordResetNamespaces(mode)
+
+ expect(namespaces).toContain('core.global')
+ expect(namespaces).toContain('core.auth.reset_password')
+ expect(namespaces).toContain('core.auth.sign_up')
+ },
+ )
+
+ it('adds the change-password copy only in change mode', () => {
+ expect(passwordResetNamespaces('change')).toContain(
+ 'core.auth.change_password',
+ )
+ expect(passwordResetNamespaces('request')).not.toContain(
+ 'core.auth.change_password',
+ )
+ })
+
+ it('warms no more than the two screens render', () => {
+ expect(passwordResetNamespaces('request')).toHaveLength(3)
+ expect(passwordResetNamespaces('change')).toHaveLength(4)
+ })
+})
+
+/**
+ * Whether this deployment has password recovery, and the third answer.
+ *
+ * The decision layer only - what the route *does* with each answer is asserted
+ * nowhere here, because that would mean rendering a router. What matters is that
+ * three inputs produce three answers rather than two, since the bug this closes
+ * was exactly two answers where three were needed.
+ */
+describe('whether this deployment has password recovery at all', () => {
+ it('follows the email adapter when the configuration was read', () => {
+ expect(passwordRecoveryAvailability({ isEmail: true, isKnown: true })).toBe(
+ 'available',
+ )
+ expect(
+ passwordRecoveryAvailability({ isEmail: false, isKnown: true }),
+ ).toBe('disabled')
+ })
+
+ /**
+ * The regression. The fallback the config query degrades to says
+ * `isEmail: false` - the right guess for the login form, which still renders
+ * its fields - and reading that as a boolean made an API outage answer 404 on
+ * this route: the application asserting the page does not exist because it
+ * could not reach its own API.
+ */
+ it('does not read an unreadable configuration as "disabled"', () => {
+ expect(passwordRecoveryAvailability(UNKNOWN_MIDDLEWARE_CONFIG)).toBe(
+ 'unknown',
+ )
+ expect(passwordRecoveryAvailability(UNKNOWN_MIDDLEWARE_CONFIG)).not.toBe(
+ 'disabled',
+ )
+ })
+
+ it('is unknown whatever the fallback happens to guess', () => {
+ // `isKnown` decides on its own: even were the fallback to start guessing
+ // `isEmail: true`, an unread configuration still may not answer "available".
+ expect(
+ passwordRecoveryAvailability({ isEmail: true, isKnown: false }),
+ ).toBe('unknown')
+ })
+
+ it('marks a configuration the API actually answered as known', () => {
+ // The other half of the contract: a real read must not look like an outage,
+ // or a deployment with no email adapter would stop answering 404.
+ expect(knownMiddlewareConfig({ isEmail: false, sso: [] }).isKnown).toBe(
+ true,
+ )
+ expect(
+ passwordRecoveryAvailability(
+ knownMiddlewareConfig({ isEmail: false, sso: [] }),
+ ),
+ ).toBe('disabled')
+ })
+
+ it('leaves the fallback usable as a login configuration', () => {
+ // The degradation password recovery must not inherit, kept deliberately: an
+ // outage still renders a login form, with no providers and no captcha.
+ expect(UNKNOWN_MIDDLEWARE_CONFIG.isEmail).toBe(false)
+ expect(UNKNOWN_MIDDLEWARE_CONFIG.sso).toEqual([])
+ expect(UNKNOWN_MIDDLEWARE_CONFIG.captcha).toBeUndefined()
+ })
+})
+
+describe('where registration sends a visitor who is already signed in', () => {
+ it('is the front page, through the same rule the login guard uses', () => {
+ // `/register` takes no `returnTo` - nothing links to it with one - so the
+ // guard's destination is whatever a finished sign-in lands on by default.
+ expect(postAuthDestination(undefined)).toBe('/')
+ })
+})
+
+describe('where the two migrated auth routes sit in the tree', () => {
+ const routeIdsFor = (pathname: string): string[] =>
+ getRouter()
+ .matchRoutes(pathname, undefined)
+ .map((match) => match.routeId)
+
+ /**
+ * Neither page is under the application shell.
+ *
+ * Stage 8 keeps `/login` and the SSO callback outside `_main`, and these two
+ * join them: they are full-height blank auth screens, and mounting the site
+ * header above a signup card would be a product change. Asserted as route
+ * *structure*, which is what decides it.
+ */
+ it.each(['/register', '/login/reset-password'])(
+ '%s renders outside the main shell',
+ (pathname) => {
+ expect(routeIdsFor(pathname)).not.toContain('/_main')
+ },
+ )
+
+ /**
+ * Password recovery must not inherit the login page's guest-only guard.
+ *
+ * A recovery link is followed out of an email, on whatever device is to hand,
+ * and a visitor already signed in elsewhere has every right to finish setting a
+ * new password - the Next.js view has never checked a session. Nested under
+ * `/login`, the guest guard would redirect them away mid-flow and burn a
+ * one-shot token. Registration, by contrast, *is* guest-only, exactly as
+ * `/login` is.
+ */
+ it('does not put password recovery under the login route', () => {
+ expect(routeIdsFor('/login/reset-password')).not.toContain('/login')
+ })
+
+ /**
+ * `/login` stays an exact match, so ownership is decided at each leaf.
+ *
+ * `matchRoutes` answers with the deepest *ancestor* it can match and leaves the
+ * rest unconsumed - which is why a `/login` with children would claim every
+ * legacy URL beneath it. Both migrated routes are non-nested siblings, so
+ * `/login` consumes exactly `/login` and an unmigrated path below it still
+ * resolves to the parent rather than to a leaf.
+ */
+ it('keeps /login consuming only its own path', () => {
+ const deepest = (pathname: string) =>
+ getRouter().matchRoutes(pathname, undefined).at(-1) as {
+ pathname: string
+ routeId: string
+ }
+
+ expect(deepest('/login')).toMatchObject({
+ pathname: '/login',
+ routeId: '/login',
+ })
+ expect(deepest('/login/reset-password')).toMatchObject({
+ pathname: '/login/reset-password',
+ routeId: '/login_/reset-password',
+ })
+ // Still nobody's: matched at `/login`, having consumed less than was asked.
+ expect(deepest('/login/something-else')).toMatchObject({
+ pathname: '/login',
+ routeId: '/login',
+ })
+ })
+})
diff --git a/apps/web/src/tests/devices-route.test.ts b/apps/web/src/tests/devices-route.test.ts
new file mode 100644
index 000000000..9fd464e3a
--- /dev/null
+++ b/apps/web/src/tests/devices-route.test.ts
@@ -0,0 +1,207 @@
+import type * as DevicesRevokeModule from '@vitnode/core/views/auth/settings/devices/devices-revoke'
+import type { RevokeDeviceResult } from '@vitnode/core/views/auth/settings/devices/devices-revoke'
+
+import { hashKey, QueryClient } from '@tanstack/react-query'
+import { devicesQueryKey } from '@vitnode/core/views/auth/settings/devices/devices-query'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * `/settings/devices`'s contract with the cache underneath it.
+ *
+ * Pure functions and one `QueryClient` held in memory. The *meaning* of a devices
+ * request - the key, the request, what a refusal is, and whether a finished
+ * revoke makes the list stale - is core's, and is asserted in
+ * `packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts`. What
+ * is asserted here is that this app asks for the right one, and that a revoke
+ * invalidates exactly the one entry it should and nothing else.
+ *
+ * The revoke's transport is stubbed rather than reached. There is no HTTP here:
+ * the only thing under test is which statuses cause an invalidation, which is the
+ * decision that replaced `revalidatePath('/[locale]/(main)', 'layout')`.
+ */
+
+/** What the stubbed browser revoke answers with on the next call. */
+let nextRevokeResult: RevokeDeviceResult = { data: true }
+
+vi.mock(
+ '@vitnode/core/views/auth/settings/devices/devices-revoke',
+ async (importOriginal) => ({
+ // Everything real except the one function that would open a socket - so
+ // `shouldRefreshAfterRevoke`, the rule actually being exercised, is core's
+ // own and not a second copy of it written for this test.
+ ...(await importOriginal()),
+ revokeDeviceInBrowser: async () => Promise.resolve(nextRevokeResult),
+ }),
+)
+
+const { devicesQuery, invalidateDevices, revokeDevice } =
+ await import('#/lib/devices/devices')
+
+/** The visitor these tests are signed in as. */
+const USER = 10
+
+/** Another visitor, whose partition must survive this one's revoke untouched. */
+const OTHER_USER = 20
+
+/** The two entries a devices invalidation must tell apart. */
+const SESSION_KEY = ['vitnode', 'session'] as const
+const MESSAGES_KEY = ['intl', 'en', 'core.global'] as const
+
+const seed = () => {
+ const queryClient = new QueryClient()
+
+ queryClient.setQueryData(devicesQuery(USER).queryKey, { devices: [] })
+ // A partition left behind by a visitor who signed out on this browser.
+ queryClient.setQueryData(devicesQuery(OTHER_USER).queryKey, { devices: [] })
+ queryClient.setQueryData(SESSION_KEY, { user: { id: USER } })
+ queryClient.setQueryData(MESSAGES_KEY, { messages: {} })
+
+ return queryClient
+}
+
+const isStale = (queryClient: QueryClient, queryKey: readonly unknown[]) =>
+ queryClient.getQueryState(queryKey)?.isInvalidated === true
+
+beforeEach(() => {
+ nextRevokeResult = { data: true }
+})
+
+describe('this app asks for core’s devices list, not its own', () => {
+ it('lands in the canonical entry', () => {
+ // The loader and the component both call `devicesQuery()`, and it has to be
+ // the entry core's own invalidation names or a revoke would refresh nothing.
+ expect(hashKey(devicesQuery(USER).queryKey)).toBe(
+ hashKey(devicesQueryKey(USER)),
+ )
+ })
+
+ it('carries no locale, because the data is the same in every language', () => {
+ // An OS name, a browser, an IP address and two timestamps do not change with
+ // the language. A locale in the key would refetch on every language switch.
+ expect(devicesQuery(USER).queryKey).toEqual(['devices', 'user', USER])
+ })
+
+ it('asks once, so a 429 is not answered by two more requests', () => {
+ expect(devicesQuery(USER).retry).toBe(false)
+ })
+
+ /**
+ * The privacy invariant at this route's own seam.
+ *
+ * The browser's `QueryClient` is created once per document and outlives a
+ * sign-out, so `["devices", "me"]` was only unique for as long as "me" was:
+ * the second visitor to sign in on one browser would have found the entry
+ * already filled, made no request, and been shown the first visitor's
+ * operating systems, browsers and IP addresses. No request means Hono never
+ * saw the read it would have refused, which is why the key is the fix.
+ */
+ it('gives two visitors two entries, so one cannot read the other’s', () => {
+ expect(hashKey(devicesQuery(USER).queryKey)).not.toBe(
+ hashKey(devicesQuery(OTHER_USER).queryKey),
+ )
+ })
+})
+
+describe('a revoke makes the devices list stale, and only that', () => {
+ it('marks the list stale when a device actually went', async () => {
+ const queryClient = seed()
+
+ await invalidateDevices(queryClient, USER)
+
+ expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true)
+ })
+
+ it('leaves everything else in the cache alone', async () => {
+ // Emphatically not `invalidateQueries()` with no key, and not
+ // `router.invalidate()`: the session and the messages have not changed
+ // because a phone was signed out. Refetching them would be the blunt version
+ // of the `revalidatePath` this replaces.
+ const queryClient = seed()
+
+ await invalidateDevices(queryClient, USER)
+
+ expect(isStale(queryClient, SESSION_KEY)).toBe(false)
+ expect(isStale(queryClient, MESSAGES_KEY)).toBe(false)
+ })
+
+ it('keeps the rows on screen while the fresh ones are fetched', async () => {
+ // Invalidating rather than removing, so the list is not blanked under a
+ // dialog that is still closing.
+ const queryClient = seed()
+
+ await invalidateDevices(queryClient, USER)
+
+ expect(queryClient.getQueryData(devicesQueryKey(USER))).toBeDefined()
+ })
+
+ it('leaves a previous visitor’s partition untouched', async () => {
+ // Prefix matching is the whole of it: one visitor's revoke names their own
+ // entry and cannot refetch a list on behalf of somebody who signed out.
+ const queryClient = seed()
+
+ await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' })
+
+ expect(isStale(queryClient, devicesQueryKey(OTHER_USER))).toBe(false)
+ })
+
+ it('does not invalidate the session, because the current device cannot be revoked', async () => {
+ // The API answers 400 for the device the request itself comes from, so no
+ // revoke reachable from this page can end the session performing it. There is
+ // no state in which a successful revoke leaves the cached session falsely
+ // authenticated - which is why this invalidation is one key rather than two.
+ const queryClient = seed()
+
+ await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' })
+
+ expect(isStale(queryClient, SESSION_KEY)).toBe(false)
+ })
+})
+
+describe('the revoke refreshes on exactly the statuses that changed something', () => {
+ it('refreshes after a success', async () => {
+ const queryClient = seed()
+ nextRevokeResult = { data: true }
+
+ await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' })
+
+ expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true)
+ })
+
+ it.each([404, 400])(
+ 'refreshes after a %i, because the row on screen was already wrong',
+ async (status) => {
+ const queryClient = seed()
+ nextRevokeResult = { error: { status } }
+
+ await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' })
+
+ expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true)
+ },
+ )
+
+ it.each([401, 403, 429, 500])(
+ 'leaves the list alone after a %i, which deleted nothing',
+ async (status) => {
+ // The refetch would be a second request into whatever refused the first: a
+ // rate limiter answered by immediately asking again, or an ended session
+ // answered by a 401 that blanks the list being read.
+ const queryClient = seed()
+ nextRevokeResult = { error: { status } }
+
+ await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' })
+
+ expect(isStale(queryClient, devicesQueryKey(USER))).toBe(false)
+ },
+ )
+
+ it('returns the finite result to the caller either way', async () => {
+ const queryClient = seed()
+ nextRevokeResult = { error: { status: 429 } }
+
+ expect(
+ await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' }),
+ ).toEqual({
+ error: { status: 429 },
+ })
+ })
+})
diff --git a/apps/web/src/tests/header-navigation.test.ts b/apps/web/src/tests/header-navigation.test.ts
index 207e26f9e..809df66f1 100644
--- a/apps/web/src/tests/header-navigation.test.ts
+++ b/apps/web/src/tests/header-navigation.test.ts
@@ -3,6 +3,11 @@ import {
HEADER_HREF,
headerNavItems,
} from '@vitnode/core/views/layouts/theme/header/header-nav'
+import {
+ USER_HEADER_HREF,
+ userHeaderMenu,
+ userProfileHref,
+} from '@vitnode/core/views/layouts/theme/header/user/user-header-model'
import { describe, expect, it } from 'vitest'
import { switchLocaleOn } from '#/lib/i18n/client'
@@ -63,6 +68,89 @@ describe('every header link is a client-side navigation', () => {
})
})
+/**
+ * The user area of the header, which is the part of it that spans the migration.
+ *
+ * `USER_HEADER_HREF` is ordinary data in `@vitnode/core` - a record of five
+ * paths, shared verbatim with the Next.js header - and it says nothing about
+ * which application serves any of them. That is the property worth pinning here
+ * rather than the individual answers: the header points at a mixture of migrated
+ * and unmigrated routes, `MigrationLink` asks the route tree per href, and the
+ * *model* needs no edit when a route moves.
+ *
+ * Stage 9 is the proof. `/settings` and `/register` were full document loads
+ * into the Next.js app when Stage 8 mounted this header; they are client-side
+ * navigations now, and the diff that did it added route files and touched
+ * neither `user-header-model.ts` nor `migration-link.tsx`.
+ */
+describe('the user menu navigates by what the route tree serves', () => {
+ const owns = (href: string): boolean =>
+ isTanStackOwnedPath(routerAt('/'), href)
+
+ /**
+ * The guest controls and the account links, split by which application renders
+ * them today. Both halves matter: the first is what Stage 9 changed, and the
+ * second is what stops "owned" from being the answer to everything.
+ */
+ it.each([
+ [USER_HEADER_HREF.files, true],
+ [USER_HEADER_HREF.settings, true],
+ [USER_HEADER_HREF.signIn, true],
+ [USER_HEADER_HREF.signUp, true],
+ // The AdminCP runs on its own session with its own sign-in and has not been
+ // migrated at all, so this must stay a document load - a client-side
+ // navigation would be a TanStack not-found where a working panel is.
+ [USER_HEADER_HREF.adminCp, false],
+ ])('%s is served by this route tree: %s', (href, expected) => {
+ expect(owns(href)).toBe(expected)
+ })
+
+ it('leaves the profile page to the application that has one', () => {
+ // `/users/` is not a route in this tree, and a name code is not a
+ // shape this app should start claiming by prefix.
+ expect(owns(userProfileHref('test-1'))).toBe(false)
+ })
+
+ /**
+ * Every item the menu actually renders, rather than every key the record
+ * holds - `userHeaderMenu` is what decides which of them a given visitor sees,
+ * and an item added to it without a route behind it is a link to a 404 in one
+ * application or a not-found in the other.
+ */
+ it('resolves every menu item a signed-in admin is shown', () => {
+ const items = userHeaderMenu({
+ avatarColor: '#000000',
+ isAdmin: true,
+ name: 'Test',
+ nameCode: 'test-1',
+ }).flat()
+
+ expect(items.map((item) => item.key)).toEqual([
+ 'my_profile',
+ 'files',
+ 'settings',
+ 'admin_cp',
+ ])
+
+ // Owned or not, every destination is an application-relative path with no
+ // locale in it: the prefix is `MigrationLink`'s to write, on whichever
+ // branch it takes.
+ for (const { href } of items) {
+ expect(href.startsWith('/')).toBe(true)
+ expect(href).not.toMatch(/^\/[a-z]{2}\//)
+ }
+ })
+
+ it('keeps the migrated ones owned when locale-prefixed', () => {
+ // A header rendered on `/pl` builds `/pl/settings`, and the prefix comes off
+ // before matching - otherwise reading Polish would silently move the whole
+ // user menu back onto the Next.js app.
+ for (const href of [USER_HEADER_HREF.settings, USER_HEADER_HREF.signUp]) {
+ expect(isTanStackOwnedPath(routerAt('/pl'), `/pl${href}`)).toBe(true)
+ }
+ })
+})
+
/**
* The language switcher, from the routes the header actually renders on.
*
diff --git a/apps/web/src/tests/intl-input.test.ts b/apps/web/src/tests/intl-input.test.ts
index a0c430da4..315c96e57 100644
--- a/apps/web/src/tests/intl-input.test.ts
+++ b/apps/web/src/tests/intl-input.test.ts
@@ -200,7 +200,11 @@ describe('hardening did not change what a valid request returns', () => {
expect(locale).toBe('pl')
expect(messages).toHaveProperty('core.global.close', 'Zamknij')
- expect(messages).toHaveProperty('core.global.loading', 'Loading...')
+ // `toggle_sidebar` is AdminCP copy the Polish override does not carry.
+ expect(messages).toHaveProperty(
+ 'core.global.toggle_sidebar',
+ 'Toggle Sidebar',
+ )
})
it('still ships only the namespaces that were asked for', async () => {
diff --git a/apps/web/src/tests/intl-provider.test.ts b/apps/web/src/tests/intl-provider.test.ts
index 7aae83d17..757fb9cca 100644
--- a/apps/web/src/tests/intl-provider.test.ts
+++ b/apps/web/src/tests/intl-provider.test.ts
@@ -4,15 +4,20 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const appSrc = resolve(dirname(fileURLToPath(import.meta.url)), '..')
-const root = readFileSync(join(appSrc, 'routes/__root.tsx'), 'utf8')
+const read = (path: string) => readFileSync(join(appSrc, path), 'utf8')
+
+const root = read('routes/__root.tsx')
+const routeMessages = read('components/route-messages.tsx')
/**
* The bug this file exists to prevent coming back.
*
* `@vitnode/core` is external to the Vite SSR pass, so it is loaded by Node,
- * and the `use-intl` it reaches through `next-intl` is a different module
- * record - a different React context - from the one this app's source imports.
- * Every `useTranslations` in the shared design system looks for that other one.
+ * which resolves `use-intl` to its `default` (production) build; this app's
+ * source goes through Vite's module runner, which resolves the very same
+ * package to its `development` build. Two files, two `createContext` calls, two
+ * React contexts - and every `useTranslations` in the shared design system
+ * looks for core's one.
*
* Providing only one of the two is a 500 on the first render of any core
* component, and - this is the part worth pinning - **only under `vite dev`**.
@@ -20,26 +25,52 @@ const root = readFileSync(join(appSrc, 'routes/__root.tsx'), 'utf8')
* server, the SSR tests and CI were all green while `pnpm dev` was broken.
* Nothing that runs in this suite can reproduce that, because Vitest resolves
* both through Node and gets one record. So the guard is on the source.
+ *
+ * Two places have to mount the pair, for two different scopes:
+ *
+ * __root -> core.global, above every route
+ * RouteMessages -> one route's own namespaces, over the root's
+ *
+ * A provider mounted in only one of them is the subtler half of the same bug:
+ * the shell renders in the right language and the page below it silently falls
+ * back to the root's messages, which hold none of the route's strings.
*/
-describe('the root provides every intl context core might read', () => {
+describe.each([
+ { name: '__root', source: root },
+ { name: 'RouteMessages', source: routeMessages },
+])('$name provides every intl context core might read', ({ source }) => {
it("mounts use-intl's provider, which this app's own code reads", () => {
- expect(root).toMatch(/import \{ IntlProvider \} from 'use-intl'/)
- expect(root).toContain('')
+ expect(source).toMatch(
+ /import \{ IntlProvider(?: as \w+)? \} from 'use-intl'/,
+ )
+ expect(source).toContain('')
})
- it("mounts next-intl's record too, which every core component reads", () => {
+ it("mounts core's own record too, which every shared component reads", () => {
// Deleting this line turns `pnpm dev` into a 500 and leaves every other
// check in this repository green. See the note in `__root.tsx`.
- expect(root).toMatch(
- /import \{ IntlProvider as NextIntlProvider \} from 'next-intl'/,
+ //
+ // It is imported from `@vitnode/core/lib/i18n/provider` rather than from
+ // `next-intl`: that module is loaded by whatever loaded the package, so it
+ // *is* the record core's components read, rather than one that happens to
+ // resolve the same way.
+ expect(source).toMatch(
+ /import \{ IntlProvider as CoreIntlProvider \} from '@vitnode\/core\/lib\/i18n\/provider'/,
)
- expect(root).toContain('')
+ expect(source).toContain('')
})
it('gives both the identical locale, messages and time zone', () => {
// Spread from one object rather than written twice: two providers that
// disagree would render half the page in the wrong language.
- expect(root).toMatch(/const intlProps = \{/)
- expect(root.match(/\{\.\.\.intlProps\}/g)).toHaveLength(2)
+ expect(source).toMatch(/const intlProps = \{/)
+ expect(source.match(/\{\.\.\.intlProps\}/g)).toHaveLength(2)
+ })
+
+ it('takes the locale from the router rather than from a second source', () => {
+ // `useLocale` is subscribed to the router's location, which is what makes a
+ // language switch re-render the provider - and what keeps the two providers
+ // from ever being handed different answers.
+ expect(source).toMatch(/const locale = useLocale\(\)/)
})
})
diff --git a/apps/web/src/tests/intl-query.test.ts b/apps/web/src/tests/intl-query.test.ts
index 40420a283..8a1308fd9 100644
--- a/apps/web/src/tests/intl-query.test.ts
+++ b/apps/web/src/tests/intl-query.test.ts
@@ -172,6 +172,32 @@ describe('the sets a client is holding', () => {
])
})
+ it('maps every mounted set onto the target language, and nothing else', () => {
+ // The warming step of a language switch, as the pure transform it is: the
+ // sets on screen in the current language become the same sets in the new
+ // one, read off the cache rather than from a list anybody maintains.
+ //
+ // Two sets are mounted on every page under the shell - the header's and the
+ // route's - and warming only the first is the bug this pins. The second
+ // provider would then suspend on a key nobody fetched, and a suspend caused
+ // by a store update cannot be deferred: the page blanks for a round trip.
+ const queryClient = clientHolding([
+ { locale: 'en' },
+ { locale: 'en', namespaces: [GLOBAL_NAMESPACE, 'core.search'] },
+ { locale: 'en', namespaces: ['core.auth.settings', GLOBAL_NAMESPACE] },
+ ])
+
+ const warmed = loadedIntlNamespaces(queryClient, 'en').map(
+ (namespaces) => intlQueryOptions({ locale: 'pl', namespaces }).queryKey,
+ )
+
+ expect(warmed).toEqual([
+ ['vitnode', 'intl', 'pl', GLOBAL_NAMESPACE],
+ ['vitnode', 'intl', 'pl', GLOBAL_NAMESPACE, 'core.search'],
+ ['vitnode', 'intl', 'pl', 'core.auth.settings', GLOBAL_NAMESPACE],
+ ])
+ })
+
it('falls back to the global set on an empty cache', () => {
// A switch made before anything has loaded still has to warm the one set
// every page needs.
diff --git a/apps/web/src/tests/isolation.test.ts b/apps/web/src/tests/isolation.test.ts
index 620893352..8340911ac 100644
--- a/apps/web/src/tests/isolation.test.ts
+++ b/apps/web/src/tests/isolation.test.ts
@@ -36,7 +36,22 @@ const filesUnder = (directory: string): string[] => {
}
/**
- * Every specifier a file imports.
+ * Type-only statements, which the compiler erases and no bundler ever follows.
+ *
+ * Dropped before the scan because this file walks the *runtime* graph, and the
+ * app's own source - unlike the `dist` it walks into - still has its `import
+ * type` lines in it. `lib/session.ts` names the API's users module purely so the
+ * route literals infer; following it would report Hono, Drizzle and the whole
+ * API tree as things a login screen loads.
+ */
+const withoutTypeImports = (source: string): string =>
+ source.replace(
+ /(?:^|\n)\s*(?:import|export)\s+type\s[\s\S]*?\sfrom\s*["'][^"']+["']/g,
+ '\n',
+ )
+
+/**
+ * Every specifier a file imports at runtime.
*
* Written to tolerate compiled output as well as source: a package's `dist` is
* minified onto one line, so `from"./x.js"` carries no whitespace and its
@@ -45,7 +60,7 @@ const filesUnder = (directory: string): string[] => {
*/
const importsFrom = (path: string): string[] =>
[
- ...readFileSync(path, 'utf8').matchAll(
+ ...withoutTypeImports(readFileSync(path, 'utf8')).matchAll(
/(?:^|[^\w$.])from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']|(?:^|[\n;}])\s*import\s*["']([^"']+)["']/g,
),
]
@@ -208,22 +223,20 @@ describe('the TanStack Start application stays Next-free', () => {
expect(offendersIn(webFiles(), NEXT_INTL_RUNTIME)).toEqual([])
})
- it('reaches for use-intl directly everywhere but the provider bridge', () => {
- // `next-intl` stays a dependency because `@vitnode/core`'s shared components
- // import its root entry, which is `use-intl` re-exported - and under
- // `vite dev` that is a second module record, so the root has to mount its
- // provider as well as `use-intl`'s (see `intl-provider.test.ts`). That one
- // file is the whole of the exception: no other module here may reach for
- // next-intl, and none may reach for anything but its root entry.
- // Runtime files only: `intl-provider.test.ts` asserts *about* that import,
- // so it necessarily contains the specifier the scanner is looking for.
+ it('reaches for use-intl directly, and never for next-intl', () => {
+ // There is no exception left. The root used to import `next-intl`'s
+ // `IntlProvider` to cover the second module record core's components read
+ // under `vite dev` (see `intl-provider.test.ts`); it now imports that record
+ // from the package that owns it, `@vitnode/core/lib/i18n/provider`. The two
+ // resolve to the same file today, and only one of them says why.
+ //
+ // Runtime files only: `intl-provider.test.ts` asserts *about* these imports,
+ // so it necessarily contains the specifiers the scanner is looking for.
const runtime = webFiles().filter(
(path) => !path.includes(`${sep}tests${sep}`),
)
- expect(offendersIn(runtime, ['next-intl'])).toEqual([
- 'apps/web/src/routes/__root.tsx',
- ])
+ expect(offendersIn(runtime, ['next-intl'])).toEqual([])
})
it('depends on use-intl at the same version next-intl resolves', () => {
@@ -362,9 +375,29 @@ describe('the whole graph this app imports stays Next-free', () => {
'apps/web/src/lib/auth/screens.ts',
'apps/web/src/lib/middleware-config.ts',
'apps/web/src/routes/_main/_authenticated.tsx',
- 'apps/web/src/routes/_main/_authenticated/account.tsx',
'apps/web/src/routes/login.tsx',
'apps/web/src/routes/login_.sso.$providerId.tsx',
+ // Stage 9. Registration reaches deeper still than the login card: the same
+ // `AutoForm` stack plus the captcha widget, the password checklist tooltip
+ // and the confirmation screen. Password recovery adds core's shared error
+ // screen on top. Both were Next-only until Stage 9 split their views.
+ 'apps/web/src/lib/auth/password-reset-route.ts',
+ 'apps/web/src/routes/register.tsx',
+ 'apps/web/src/routes/login_.reset-password.tsx',
+ // Stage 9. The settings subtree, which is the first *nested layout* this app
+ // renders and the first place the shared settings frame - the navigation
+ // card, the mobile back link, the panel card - is mounted outside Next.js.
+ // The devices panel is the one with data, so its graph reaches core's list,
+ // its revoke and the confirm dialog behind the revoke button.
+ 'apps/web/src/components/layout/settings-breadcrumb.tsx',
+ 'apps/web/src/lib/devices/devices.ts',
+ 'apps/web/src/lib/settings/panel.ts',
+ 'apps/web/src/routes/_main/_authenticated/settings.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/devices.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/index.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/overview.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/security.tsx',
+ 'apps/web/src/server/devices.server.ts',
// Stage 7. `/files` renders the whole data table - eight columns, the
// bulk-action bar and both confirm dialogs - which is the deepest this app
// reaches into the design system after the auth screens. That graph was
@@ -660,6 +693,86 @@ describe('the whole graph this app imports stays Next-free', () => {
expect(reached.filter((one) => one.includes('navigation'))).toEqual([])
})
})
+
+ /**
+ * Every migrated screen at once: the shared client contract is `use-intl`.
+ *
+ * The per-route blocks above ban `next-intl`'s *subpaths*, which reach Next's
+ * request scope and simply do not resolve here. This bans the root entry too,
+ * across everything this app renders, and that is a different bug it is
+ * closing.
+ *
+ * `next-intl`'s root re-exports `use-intl/react`, so a shared component that
+ * imports it *does* read the context core's provider supplies - today. It is
+ * a coincidence of how one package re-exports another, and it held only
+ * because every design-system component that reached for it happened to read
+ * `core.global`, which the root provides to every page. A component that read
+ * a route's own namespace through a second record would render the root's
+ * messages instead: no error, no missing key, just a page in the wrong
+ * language below a shell in the right one. That is the failure this asserts
+ * away, rather than trusting the re-export to keep pointing where it does.
+ *
+ * `routes/api/$` is deliberately not in the list. It mounts the Hono API,
+ * which renders emails with `createTranslator` from `next-intl`'s root - the
+ * framework-free half, on a server, in a graph that renders no React. The
+ * boundary here is about what the *browser* and the SSR pass render.
+ */
+ describe('every migrated screen takes its translations from use-intl', () => {
+ /** One entry per route file the router can render, plus the shell slots. */
+ const RENDERED = [
+ 'apps/web/src/routes/__root.tsx',
+ 'apps/web/src/routes/_main.tsx',
+ 'apps/web/src/routes/_main/index.tsx',
+ 'apps/web/src/routes/_main/discover.tsx',
+ 'apps/web/src/routes/_main/search.tsx',
+ 'apps/web/src/routes/_main/_authenticated.tsx',
+ 'apps/web/src/routes/_main/_authenticated/files.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/index.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/overview.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/devices.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/security.tsx',
+ 'apps/web/src/routes/login.tsx',
+ 'apps/web/src/routes/login_.reset-password.tsx',
+ 'apps/web/src/routes/login_.sso.$providerId.tsx',
+ 'apps/web/src/routes/register.tsx',
+ 'apps/web/src/components/header.tsx',
+ 'apps/web/src/components/layout/main-breadcrumb.tsx',
+ 'apps/web/src/components/layout/main-header.tsx',
+ 'apps/web/src/components/layout/settings-breadcrumb.tsx',
+ 'apps/web/src/components/layout/user-header.tsx',
+ 'apps/web/src/components/route-messages.tsx',
+ ]
+
+ it('walks into the design system, where the imports it bans live', () => {
+ // Without this the assertion below would pass on a graph that stopped at
+ // the route files - which is exactly the graph that cannot break. These
+ // four are the components that reached for `next-intl` before this stage.
+ const reached = [...reachableExternals(RENDERED).visited]
+
+ for (const module of [
+ 'components/form/auto-form',
+ 'components/table/content',
+ 'components/ui/button-client',
+ 'components/confirm-action/confirm-action-alert-dialog',
+ ]) {
+ expect(
+ reached.some((path) => path.includes(module)),
+ module,
+ ).toBe(true)
+ }
+ })
+
+ it('reaches use-intl', () => {
+ expect([...reachableExternals(RENDERED).externals.keys()]).toContain(
+ 'use-intl',
+ )
+ })
+
+ it('never reaches next-intl, root entry included', () => {
+ expect(offenders(RENDERED, ['next-intl'])).toEqual([])
+ })
+ })
})
/**
diff --git a/apps/web/src/tests/locale-ssr.test.ts b/apps/web/src/tests/locale-ssr.test.ts
index d146761dc..3c8b6bb7f 100644
--- a/apps/web/src/tests/locale-ssr.test.ts
+++ b/apps/web/src/tests/locale-ssr.test.ts
@@ -60,9 +60,13 @@ describe('SSR serves one page in two languages', () => {
})
it('falls back to English for a key Polish does not translate', async () => {
+ // The rule this pins is that a language may be incomplete: `toggle_sidebar`
+ // is AdminCP copy the Polish override does not carry, and it renders in
+ // English on a page whose every other string is Polish. A translation is
+ // merged key by key over the default locale, never all-or-nothing.
const { html } = await renderPage(at('/pl'))
- expect(testId(html, 'loading')).toBe('Loading...')
+ expect(testId(html, 'fallback')).toBe('Toggle Sidebar')
})
it('gives the two URLs the same route and different public hrefs', async () => {
diff --git a/apps/web/src/tests/main-shell.test.ts b/apps/web/src/tests/main-shell.test.ts
index c7f198843..a919ecb5f 100644
--- a/apps/web/src/tests/main-shell.test.ts
+++ b/apps/web/src/tests/main-shell.test.ts
@@ -40,8 +40,15 @@ describe('the main shell is what a public page renders inside', () => {
['/', 'the front page'],
['/discover', 'the discover feed'],
['/search', 'the search page'],
- ['/account', 'a page behind the session guard'],
- ['/files', 'the files table, behind the same guard'],
+ ['/files', 'the files table, behind the session guard'],
+ // Stage 9. The settings subtree joins the shell rather than bringing a
+ // second header of its own: the layout is a child of the guard, which is a
+ // child of the shell, so a panel gets the header, the breadcrumb area, the
+ // `` landmark and the guard from where its file lives.
+ ['/settings', 'the settings root, behind the same guard'],
+ ['/settings/overview', 'a settings panel'],
+ ['/settings/devices', 'the devices panel'],
+ ['/settings/security', 'the security panel'],
['/example', "a plugin's page, mounted by area rather than by file"],
])('%s renders in the shell (%s)', (pathname) => {
expect(matchedIds(pathname)).toContain(MAIN_SHELL_ROUTE_ID)
@@ -50,12 +57,17 @@ describe('the main shell is what a public page renders inside', () => {
/**
* An auth screen is a full-height card on an otherwise empty document, and the
* header it would render has one interesting control on it: "sign in". Keeping
- * these out is what makes the shell something routes opt into - and it is the
- * shape `/register` and the password-reset screens will want when they move.
+ * these out is what makes the shell something routes opt into.
+ *
+ * Stage 9 is what makes that a policy rather than an accident of what had been
+ * migrated: registration and password recovery moved in, and they moved in
+ * *here* - outside the shell, alongside `/login` - rather than under `_main`.
*/
it.each([
['/login', 'the login screen'],
['/login/sso/google', 'the SSO callback'],
+ ['/register', 'the registration screen'],
+ ['/login/reset-password', 'the password-recovery screens'],
])('%s renders outside it (%s)', (pathname) => {
expect(matchedIds(pathname)).not.toContain(MAIN_SHELL_ROUTE_ID)
})
@@ -132,12 +144,21 @@ describe('the shell owns the main landmark', () => {
* them, a login screen with no `` is a document with no main landmark at
* all.
*/
- it.each(['login.tsx', 'login_.sso.$providerId.tsx'])(
- '%s renders exactly one of its own',
- (name) => {
- expect(landmarks(withoutComments(join(routesDir, name)))).toHaveLength(1)
- },
- )
+ it.each([
+ ['login.tsx', 1],
+ ['login_.sso.$providerId.tsx', 1],
+ // Stage 9. Registration and password recovery join the blank-auth area, so
+ // they own their landmark for the same reason.
+ ['register.tsx', 1],
+ // Two, and both correct: the page body and the route's own
+ // `notFoundComponent`, which replaces it on an install with no email
+ // adapter. They are alternatives, so a document still renders exactly one.
+ ['login_.reset-password.tsx', 2],
+ ] as const)('%s renders %i of its own', (name, count) => {
+ expect(landmarks(withoutComments(join(routesDir, name)))).toHaveLength(
+ count,
+ )
+ })
/**
* The same rule, for the pages this app does not own.
diff --git a/apps/web/src/tests/messages.test.ts b/apps/web/src/tests/messages.test.ts
index 474b126f6..00b89e603 100644
--- a/apps/web/src/tests/messages.test.ts
+++ b/apps/web/src/tests/messages.test.ts
@@ -65,15 +65,20 @@ describe('loading one language for one set of namespaces', () => {
})
it('falls back to the default locale key by key', async () => {
- // Polish translates five strings. Everything else has to keep rendering
- // English rather than degrading to `core.global.loading`.
+ // Polish translates what the migrated routes render and nothing else.
+ // `toggle_sidebar` is AdminCP copy it deliberately leaves out, and it has
+ // to keep rendering English rather than degrading to
+ // `core.global.toggle_sidebar`. A language is never all-or-nothing.
const { messages } = await loadIntlMessages({
locale: 'pl',
namespaces: ['core.global'],
})
expect(messages).toHaveProperty('core.global.save', 'Zapisz')
- expect(messages).toHaveProperty('core.global.loading', 'Loading...')
+ expect(messages).toHaveProperty(
+ 'core.global.toggle_sidebar',
+ 'Toggle Sidebar',
+ )
})
it('merges app overrides on top of what the package ships', async () => {
diff --git a/apps/web/src/tests/my-files-route.test.ts b/apps/web/src/tests/my-files-route.test.ts
index eb1691a38..9b7a533e7 100644
--- a/apps/web/src/tests/my-files-route.test.ts
+++ b/apps/web/src/tests/my-files-route.test.ts
@@ -9,7 +9,7 @@ import {
} from '@vitnode/core/components/table/url-state'
import {
MY_FILES_MAX_PAGE_SIZE,
- MY_FILES_QUERY_ROOT,
+ myFilesQueryRoot,
} from '@vitnode/core/views/files/my-files-query'
import { describe, expect, it } from 'vitest'
@@ -48,10 +48,17 @@ import { getRouter } from '#/router'
const searchFor = (query: string) =>
normalizeMyFilesRouteSearch(defaultParseSearch(query))
-/** The cache entry one URL lands in. */
-const keyFor = (query: string) =>
+/** The visitor these tests are signed in as, wherever an owner is needed. */
+const USER = 10
+
+/** Another visitor, for the entries that must never be shared with them. */
+const OTHER_USER = 20
+
+/** The cache entry one URL lands in, for one visitor. */
+const keyFor = (query: string, userId: number = USER) =>
hashKey(
- myFilesQuery({ params: myFilesRouteParams(searchFor(query)) }).queryKey,
+ myFilesQuery({ params: myFilesRouteParams(searchFor(query)), userId })
+ .queryKey,
)
describe('the route schema reads a table request out of the URL', () => {
@@ -210,12 +217,28 @@ describe('one URL, one cache entry', () => {
})
it('hangs off the root a delete invalidates', () => {
+ const root = myFilesQueryRoot(USER)
+
expect(
- myFilesQuery({ params: myFilesRouteParams({}) }).queryKey.slice(
- 0,
- MY_FILES_QUERY_ROOT.length,
- ),
- ).toEqual([...MY_FILES_QUERY_ROOT])
+ myFilesQuery({
+ params: myFilesRouteParams({}),
+ userId: USER,
+ }).queryKey.slice(0, root.length),
+ ).toEqual([...root])
+ })
+
+ /**
+ * The privacy invariant at this route's own seam.
+ *
+ * The key contract is core's and is asserted there; what is asserted here is
+ * that *this route's* query definition carries the owner through, so the entry
+ * a loader fills for one visitor cannot be the entry another visitor's loader
+ * reads. Same URL, same normalised parameters, two visitors, two entries.
+ */
+ it('gives two visitors two entries for the identical URL', () => {
+ for (const query of ['', 'orderBy=name&order=asc', 'search=logo']) {
+ expect(keyFor(query, USER)).not.toBe(keyFor(query, OTHER_USER))
+ }
})
})
@@ -356,17 +379,27 @@ describe('a delete makes the visitor’s files stale, and only those', () => {
const queryClient = new QueryClient()
const firstPage = myFilesQuery({
params: myFilesRouteParams(searchFor('')),
+ userId: USER,
})
const sorted = myFilesQuery({
params: myFilesRouteParams(searchFor('orderBy=name&order=asc')),
+ userId: USER,
+ })
+ // A partition left behind by a visitor who signed out on this browser. It is
+ // unreachable - every authenticated route builds its key from the current
+ // session - and a delete must not reach it either.
+ const otherVisitor = myFilesQuery({
+ params: myFilesRouteParams(searchFor('')),
+ userId: OTHER_USER,
})
const session = ['vitnode', 'session'] as const
queryClient.setQueryData(firstPage.queryKey, { edges: [], pageInfo: {} })
queryClient.setQueryData(sorted.queryKey, { edges: [], pageInfo: {} })
- queryClient.setQueryData(session, { user: { id: 1 } })
+ queryClient.setQueryData(otherVisitor.queryKey, { edges: [], pageInfo: {} })
+ queryClient.setQueryData(session, { user: { id: USER } })
- return { firstPage, queryClient, session, sorted }
+ return { firstPage, otherVisitor, queryClient, session, sorted }
}
const isStale = (queryClient: QueryClient, queryKey: readonly unknown[]) =>
@@ -377,18 +410,29 @@ describe('a delete makes the visitor’s files stale, and only those', () => {
// pressing a button - and reads from the cache - are wrong too.
const { firstPage, queryClient, sorted } = seed()
- void invalidateMyFiles(queryClient)
+ void invalidateMyFiles(queryClient, USER)
expect(isStale(queryClient, firstPage.queryKey)).toBe(true)
expect(isStale(queryClient, sorted.queryKey)).toBe(true)
})
+ it('leaves a previous visitor’s partition untouched', () => {
+ // Prefix matching is the whole of it: `['files','user',10]` is not a prefix
+ // of `['files','user',20,...]`, so one visitor's delete cannot refetch a
+ // list on behalf of somebody who has signed out.
+ const { otherVisitor, queryClient } = seed()
+
+ void invalidateMyFiles(queryClient, USER)
+
+ expect(isStale(queryClient, otherVisitor.queryKey)).toBe(false)
+ })
+
it('leaves everything else in the cache alone', () => {
// Emphatically not `invalidateQueries()` with no key: the session and the
// messages have not changed because a file was deleted.
const { queryClient, session } = seed()
- void invalidateMyFiles(queryClient)
+ void invalidateMyFiles(queryClient, USER)
expect(isStale(queryClient, session)).toBe(false)
})
@@ -398,7 +442,7 @@ describe('a delete makes the visitor’s files stale, and only those', () => {
// dialog that is still open.
const { firstPage, queryClient } = seed()
- void invalidateMyFiles(queryClient)
+ void invalidateMyFiles(queryClient, USER)
expect(queryClient.getQueryData(firstPage.queryKey)).toBeDefined()
})
diff --git a/apps/web/src/tests/plugin-routes.test.ts b/apps/web/src/tests/plugin-routes.test.ts
index 55566fa0b..248267e7a 100644
--- a/apps/web/src/tests/plugin-routes.test.ts
+++ b/apps/web/src/tests/plugin-routes.test.ts
@@ -398,19 +398,25 @@ 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.
+ // Stage 6. `/login` is migrated, and so are its two siblings - none of them
+ // nested under it, which is what keeps ownership a per-leaf answer.
['/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],
- // Stage 7. `/search` is a plain route; `/files` is a second page behind the
- // pathless guard, so owning it must still be decided at `/files` and not at
- // the boundary above it.
+ // Stage 9. Registration and password recovery, both outside the main shell
+ // and both non-nested siblings of `/login` - see `src/tests/auth-routes.test.ts`
+ // for why recovery in particular must not sit under it.
+ ['/register', true],
+ ['/pl/register', true],
+ ['/login/reset-password', true],
+ ['/pl/login/reset-password', true],
+ // The case owning `/login` most easily annexes by accident: a path below it
+ // that nobody has migrated. `matchRoutes` answers with `/login` and leaves
+ // the rest unconsumed - see the note below.
+ ['/login/something-else', false],
+ // Stage 7. `/search` is a plain route; `/files` is a page behind the
+ // pathless `_authenticated` guard - which adds no URL segment - so owning it
+ // must still be decided at `/files` and not at the boundary above it.
['/search', true],
['/pl/search', true],
['/files', true],
@@ -419,30 +425,47 @@ describe("the app's real route tree", () => {
// takes a pathname - so a table URL is the shape that would break if the
// query were not stripped before matching.
['/files?orderBy=name&order=asc&first=20', true],
- // Still the Next.js app's, and the case a migrated `/files` most easily
- // annexes by accident: `/settings` is a sibling of nothing here, so a
- // prefix-matching rule would answer for it. `/settings/security` is the
- // nested one - see the `/login` note below for why that distinction is
- // load-bearing rather than decorative.
- ['/settings', false],
- ['/settings/security', false],
- ['/pl/settings/security', false],
+ // Stage 9. `/settings` is a nested *layout* route with an index child, and
+ // each panel is a page two segments deep beneath it - so owning one is
+ // decided at its own path, and neither the pathless guard above nor the
+ // layout itself answers for it. `/settings` is owned because of the index
+ // child, not because the layout matched.
+ ['/settings', true],
+ ['/pl/settings', true],
+ ['/settings/overview', true],
+ ['/settings/devices', true],
+ ['/pl/settings/devices', true],
+ ['/settings/security', true],
+ ['/pl/settings/security', true],
+ // The case a migrated `/settings` most easily annexes by accident: a panel
+ // that does not exist. The layout matches `/settings` and leaves the rest
+ // unconsumed, so a prefix-matching rule would hand a page the Next.js app
+ // still serves to this router - see the `/login` note below for why that
+ // distinction is load-bearing rather than decorative.
+ ['/settings/notifications', false],
+ ['/pl/settings/notifications', false],
])('answers %s as owned: %s', (href, owned) => {
expect(isTanStackOwnedPath(getRouter(), href)).toBe(owned)
})
/**
- * Owning `/login` must not quietly annex the legacy routes beneath it.
+ * Owning `/login` must not quietly annex the paths 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.
+ * If the SSO callback or the reset-password page were *children* of `/login`,
+ * that route would match every path below it as a prefix, and `MigrationLink`
+ * would hand a page the Next.js app still serves to this router as a
+ * client-side navigation - a working page turning into a TanStack not-found.
+ * All three are therefore non-nested siblings (`login.tsx`,
+ * `login_.sso.$providerId.tsx`, `login_.reset-password.tsx`), which is what
+ * these assertions pin: exact leaves, no shared parent.
+ *
+ * `/login/something-else` is the case that still exercises it now that both
+ * real siblings are migrated - `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.
*/
- it('keeps /login an exact match, so the legacy routes under it stay legacy', () => {
+ it('keeps /login an exact match, so unmigrated paths under it stay legacy', () => {
const router = getRouter()
const deepest = (pathname: string) =>
router.matchRoutes(pathname, undefined).at(-1) as {
@@ -456,16 +479,13 @@ describe("the app's real route tree", () => {
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({
+ // A path below it that no route declares resolves to `/login` - the route id
+ // alone says "owned" here, and it is not.
+ expect(deepest('/login/something-else')).toMatchObject({
pathname: '/login',
routeId: '/login',
})
- expect(isTanStackOwnedPath(router, '/login/reset-password')).toBe(false)
+ expect(isTanStackOwnedPath(router, '/login/something-else')).toBe(false)
})
/**
diff --git a/apps/web/src/tests/recovery-contract.test.ts b/apps/web/src/tests/recovery-contract.test.ts
new file mode 100644
index 000000000..84dea09a0
--- /dev/null
+++ b/apps/web/src/tests/recovery-contract.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ changePasswordInputSchema,
+ changePasswordResultFromStatus,
+ passwordResetRequestInputSchema,
+ passwordResetRequestResultFromStatus,
+} from '#/lib/auth/contract'
+
+/**
+ * The two password-recovery mutations' decisions, without the transport.
+ *
+ * The interesting property here is not a mapping but an *absence*: there is no
+ * result the reset-request path can produce that says whether an address belongs
+ * to an account, because the API answers the same 201 either way. Several of the
+ * tests below exist to keep that true.
+ */
+
+/** What the API actually puts in the email: 32 random bytes as base64url. */
+const TOKEN = 'PSyRy0nQ0hRnfx3iCYldQ40mBLU9lqfDWtvNhrTsJI4'
+
+describe('reset-request results', () => {
+ it('reads a 201 as accepted', () => {
+ expect(passwordResetRequestResultFromStatus(201)).toEqual({ ok: true })
+ })
+
+ it('answers the same way whether or not the address exists', () => {
+ // Not a tautology: the API returns 201 for an unknown address, for a known
+ // one, and for a known one it decided not to email because a link was already
+ // requested in the last five minutes. One status, one result, nothing to
+ // enumerate.
+ expect(passwordResetRequestResultFromStatus(201)).toEqual({ ok: true })
+ })
+
+ it('has no reason that could mean "no such account"', () => {
+ const reasons = new Set(
+ [400, 429, 500, 503, 200].map((status) => {
+ const result = passwordResetRequestResultFromStatus(status)
+
+ return result.ok ? 'ok' : result.reason
+ }),
+ )
+
+ expect([...reasons].sort()).toEqual([
+ 'invalid',
+ 'rate_limited',
+ 'server_error',
+ ])
+ })
+
+ it('reads a 400 as an invalid submission - which is also a refused captcha', () => {
+ expect(passwordResetRequestResultFromStatus(400)).toEqual({
+ ok: false,
+ reason: 'invalid',
+ })
+ })
+
+ it('keeps the rate limiter apart from a server failure', () => {
+ expect(passwordResetRequestResultFromStatus(429)).toEqual({
+ ok: false,
+ reason: 'rate_limited',
+ })
+ })
+
+ it.each([200, 204, 403, 404, 500, 503])(
+ 'collapses %i into one server_error',
+ (status) => {
+ expect(passwordResetRequestResultFromStatus(status)).toEqual({
+ ok: false,
+ reason: 'server_error',
+ })
+ },
+ )
+})
+
+describe('change-password results', () => {
+ it('reads a 201 as changed', () => {
+ expect(changePasswordResultFromStatus(201)).toEqual({ ok: true })
+ })
+
+ it('reads a 400 as a link that cannot be used', () => {
+ // The API looks the row up by userId AND token AND an unexpired expiresAt, so
+ // a wrong link, a spent link and a link older than thirty minutes are one
+ // status - and "ask for a fresh one" is the answer to all three.
+ expect(changePasswordResultFromStatus(400)).toEqual({
+ ok: false,
+ reason: 'invalid_token',
+ })
+ })
+
+ it('keeps the rate limiter apart from a server failure', () => {
+ expect(changePasswordResultFromStatus(429)).toEqual({
+ ok: false,
+ reason: 'rate_limited',
+ })
+ })
+
+ it.each([200, 403, 404, 409, 500, 503])(
+ 'collapses %i into one server_error',
+ (status) => {
+ expect(changePasswordResultFromStatus(status)).toEqual({
+ ok: false,
+ reason: 'server_error',
+ })
+ },
+ )
+})
+
+describe('the reset-request input schema', () => {
+ it('lower-cases the address, as the API does before it looks one up', () => {
+ expect(
+ passwordResetRequestInputSchema.parse({
+ captchaToken: 'token',
+ email: 'Test@Test.com',
+ }).email,
+ ).toBe('test@test.com')
+ })
+
+ it('treats a missing captcha token as an empty one', () => {
+ expect(
+ passwordResetRequestInputSchema.parse({ email: 'test@test.com' })
+ .captchaToken,
+ ).toBe('')
+ })
+
+ it.each([
+ ['a value that is not an email address', { email: 'test' }],
+ ['an unbounded captcha token', { captchaToken: 'a'.repeat(8193) }],
+ ])('rejects %s', (_case, patch) => {
+ expect(
+ passwordResetRequestInputSchema.safeParse({
+ captchaToken: 'token',
+ email: 'test@test.com',
+ ...patch,
+ }).success,
+ ).toBe(false)
+ })
+})
+
+describe('the change-password input schema', () => {
+ const valid = { password: 'Test123!', token: TOKEN, userId: 123 }
+
+ it('accepts what a parsed recovery link plus a password looks like', () => {
+ expect(changePasswordInputSchema.parse(valid)).toEqual(valid)
+ })
+
+ it.each([
+ ['a userId that is still a string', { userId: '123' }],
+ ['a zero userId', { userId: 0 }],
+ ['a negative userId', { userId: -1 }],
+ ['a fractional userId', { userId: 1.5 }],
+ ['a userId past the safe integer range', { userId: 2 ** 53 }],
+ ['a token with a path separator', { token: `../${TOKEN}` }],
+ ['a token with a space', { token: `${TOKEN} x` }],
+ ['a token too short to be one', { token: 'abc' }],
+ ['an unbounded token', { token: 'a'.repeat(513) }],
+ ['a seven-character password', { password: 'Test12!' }],
+ ['an unbounded password', { password: 'a'.repeat(1025) }],
+ ])('rejects %s rather than forwarding it', (_case, patch) => {
+ // This runs on the server-function boundary, where the input is whatever a
+ // caller posted - not whatever the recovery URL contained.
+ expect(
+ changePasswordInputSchema.safeParse({ ...valid, ...patch }).success,
+ ).toBe(false)
+ })
+})
diff --git a/apps/web/src/tests/registration-contract.test.ts b/apps/web/src/tests/registration-contract.test.ts
new file mode 100644
index 000000000..07ad9a261
--- /dev/null
+++ b/apps/web/src/tests/registration-contract.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ shouldRefreshSessionAfterSignUp,
+ signUpInputSchema,
+ signUpResultFromStatus,
+} from '#/lib/auth/contract'
+
+/**
+ * The registration transport's decisions, without the transport.
+ *
+ * Every status the sign-up route can answer, and every shape its `201` body can
+ * arrive in, 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.
+ */
+
+const success = { email: 'test@test.com', emailVerified: true }
+
+describe('sign-up results', () => {
+ it('reads a 201 as an account, carrying the address and the flag', () => {
+ expect(signUpResultFromStatus(201, { body: success })).toEqual({
+ email: 'test@test.com',
+ emailVerified: true,
+ ok: true,
+ })
+ })
+
+ it('keeps an unverified account distinct from a verified one', () => {
+ expect(
+ signUpResultFromStatus(201, {
+ body: { email: 'test@test.com', emailVerified: false },
+ }),
+ ).toEqual({
+ email: 'test@test.com',
+ emailVerified: false,
+ ok: true,
+ })
+ })
+
+ it.each([
+ ['no body at all', undefined],
+ ['a body with no flag', { email: 'test@test.com' }],
+ [
+ 'a flag that is not a boolean',
+ { email: 'a@b.com', emailVerified: 'yes' },
+ ],
+ ['a body with no address', { emailVerified: true }],
+ ['a string', 'created'],
+ ['null', null],
+ ])('refuses to read %s as a session rather than guessing', (_case, body) => {
+ // `emailVerified` decides whether the visitor now holds a session cookie.
+ // A body that cannot be parsed must not read as `false` by accident, so an
+ // unreadable 201 is a server error.
+ expect(signUpResultFromStatus(201, { body })).toEqual({
+ ok: false,
+ reason: 'server_error',
+ })
+ })
+
+ it('reads a 400 as an invalid submission - which is also a refused captcha', () => {
+ // `captchaMiddleware` answers 400 for both "token is required" and
+ // "validation failed", and the API gives a caller no way to tell those from a
+ // body its schema rejected.
+ expect(signUpResultFromStatus(400)).toEqual({
+ ok: false,
+ reason: 'invalid',
+ })
+ })
+
+ it.each([
+ ['Email already exists', 'email_exists'],
+ ['Name already exists', 'name_exists'],
+ ['{"error":"Email already exists"}', 'email_exists'],
+ ])('pins a 409 saying %s to a field', (conflict, reason) => {
+ expect(signUpResultFromStatus(409, { conflict })).toEqual({
+ ok: false,
+ reason,
+ })
+ })
+
+ it.each([undefined, '', 'Something else', 'Name code already exists'])(
+ 'reads a 409 nobody could classify (%s) as a plain conflict',
+ (conflict) => {
+ expect(signUpResultFromStatus(409, { conflict })).toEqual({
+ ok: false,
+ reason: 'conflict',
+ })
+ },
+ )
+
+ it('keeps the rate limiter apart from a server failure', () => {
+ // `notifyRateLimited` - the toast the browser fetcher raises - is a no-op on
+ // a server, so a mutation behind a server function is the only place a 429
+ // can be observed at all.
+ expect(signUpResultFromStatus(429)).toEqual({
+ ok: false,
+ reason: 'rate_limited',
+ })
+ })
+
+ it.each([200, 202, 403, 404, 500, 503])(
+ 'collapses %i into one server_error',
+ (status) => {
+ expect(signUpResultFromStatus(status)).toEqual({
+ ok: false,
+ reason: 'server_error',
+ })
+ },
+ )
+
+ it('never carries the API error body into the result', () => {
+ const result = signUpResultFromStatus(409, {
+ conflict: 'Email already exists at /api/@vitnode/core/users/sign_up',
+ })
+
+ expect(JSON.stringify(result)).not.toContain('api')
+ })
+})
+
+describe('the sign-up input schema', () => {
+ const valid = {
+ captchaToken: 'token',
+ email: 'Test@Test.com',
+ name: 'tester',
+ password: 'Test123!',
+ }
+
+ it('lower-cases the address, exactly as the API does before it looks one up', () => {
+ expect(signUpInputSchema.parse(valid).email).toBe('test@test.com')
+ })
+
+ it('treats a missing captcha token as an empty one', () => {
+ // `useCaptcha` reports itself ready with no token when this deployment has no
+ // captcha configured, and the API's middleware is a no-op in that case.
+ const { captchaToken, ...rest } = valid
+
+ expect(captchaToken).toBe('token')
+ expect(signUpInputSchema.parse(rest).captchaToken).toBe('')
+ })
+
+ it.each([
+ ['a name with doubled spaces', { name: 'te ster' }],
+ ['a name with a slash', { name: 'te/ster' }],
+ ['a name with a newline', { name: 'tes\nter' }],
+ ['a two-character name', { name: 'ab' }],
+ ['a name past 32 characters', { name: 'a'.repeat(33) }],
+ ['a seven-character password', { password: 'Test12!' }],
+ ['an unbounded password', { password: 'a'.repeat(1025) }],
+ ['an unbounded captcha token', { captchaToken: 'a'.repeat(8193) }],
+ ['a value that is not an email address', { email: 'test' }],
+ ])('rejects %s', (_case, patch) => {
+ expect(signUpInputSchema.safeParse({ ...valid, ...patch }).success).toBe(
+ false,
+ )
+ })
+
+ it.each([
+ ['letters beyond ASCII', 'Zażółć gęślą'],
+ ['digits', 'tester2000'],
+ ['the punctuation the API allows', 'te.st_er-name@x'],
+ ])('accepts %s in a name, as the API does', (_case, name) => {
+ expect(signUpInputSchema.safeParse({ ...valid, name }).success).toBe(true)
+ })
+
+ it('does not accept a terms field it would forward', () => {
+ // The tick is a local precondition; the API has no field for it, so it is
+ // stripped rather than sent.
+ const parsed = signUpInputSchema.parse({ ...valid, terms: true })
+
+ expect(parsed).not.toHaveProperty('terms')
+ })
+})
+
+describe('whether registration produced a session to go and read', () => {
+ it('refreshes only for a verified account', () => {
+ // Which is exactly when the API called `createSessionByUserId` on the same
+ // request, so the 201 carried the cookie `saveApiCookies` has just written.
+ expect(shouldRefreshSessionAfterSignUp({ ...success, ok: true })).toBe(true)
+ })
+
+ it('does not pretend an unverified visitor is signed in', () => {
+ expect(
+ shouldRefreshSessionAfterSignUp({
+ email: 'test@test.com',
+ emailVerified: false,
+ ok: true,
+ }),
+ ).toBe(false)
+ })
+
+ it.each([
+ 'conflict',
+ 'email_exists',
+ 'invalid',
+ 'name_exists',
+ 'rate_limited',
+ 'server_error',
+ ] as const)('does not refresh after a %s failure', (reason) => {
+ expect(shouldRefreshSessionAfterSignUp({ ok: false, reason })).toBe(false)
+ })
+})
diff --git a/apps/web/src/tests/registration-screens.test.ts b/apps/web/src/tests/registration-screens.test.ts
new file mode 100644
index 000000000..47793837c
--- /dev/null
+++ b/apps/web/src/tests/registration-screens.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ changePasswordFormResult,
+ passwordResetFormResult,
+ signUpFormResult,
+} from '#/lib/auth/screens'
+
+/**
+ * The registration and recovery contracts translated into the vocabulary
+ * `@vitnode/core`'s shared forms speak. Total functions over finite unions, so
+ * every outcome the API can produce is checked here rather than in a browser.
+ */
+
+describe('signUpFormResult', () => {
+ it('says nothing for a verified account, which is how the form knows the caller is leaving', () => {
+ expect(
+ signUpFormResult({
+ email: 'test@test.com',
+ emailVerified: true,
+ ok: true,
+ }),
+ ).toBeUndefined()
+ })
+
+ it('asks for the confirmation screen when the account is not verified', () => {
+ // The visitor is *not* signed in here, and this is the shape that says so:
+ // the form swaps itself for "check your email" instead of standing down.
+ expect(
+ signUpFormResult({
+ email: 'test@test.com',
+ emailVerified: false,
+ ok: true,
+ }),
+ ).toEqual({ emailConfirmation: 'test@test.com' })
+ })
+
+ it.each([
+ ['email_exists', 'email_exists'],
+ ['name_exists', 'name_exists'],
+ ] as const)(
+ 'passes %s through so the right field is marked',
+ (reason, message) => {
+ expect(signUpFormResult({ ok: false, reason })).toEqual({ message })
+ },
+ )
+
+ it.each(['conflict', 'invalid', 'rate_limited', 'server_error'] as const)(
+ 'renders %s as the internal-error toast',
+ (reason) => {
+ // Deliberate collapse: a visitor cannot act on the difference between a
+ // 409 whose field we could not name, a refused captcha and a rate limit.
+ // The distinctions survive in the server log.
+ expect(signUpFormResult({ ok: false, reason })).toEqual({
+ message: 'Internal Server Error',
+ })
+ },
+ )
+
+ it('never returns a shape that both stands down and asks for the confirmation screen', () => {
+ const unverified = signUpFormResult({
+ email: 'test@test.com',
+ emailVerified: false,
+ ok: true,
+ })
+
+ expect(unverified).toBeDefined()
+ expect(unverified?.message).toBeUndefined()
+ })
+})
+
+describe('passwordResetFormResult', () => {
+ it('says nothing for an accepted request', () => {
+ expect(passwordResetFormResult({ ok: true })).toBeUndefined()
+ })
+
+ it.each(['invalid', 'rate_limited', 'server_error'] as const)(
+ 'renders %s as the internal-error toast',
+ (reason) => {
+ expect(passwordResetFormResult({ ok: false, reason })).toEqual({
+ message: 'Internal Server Error',
+ })
+ },
+ )
+})
+
+describe('changePasswordFormResult', () => {
+ it('says nothing on success - the form raises its own toast and leaves', () => {
+ expect(changePasswordFormResult({ ok: true })).toBeUndefined()
+ })
+
+ it('keeps an unusable link as itself, because the visitor can act on it', () => {
+ expect(
+ changePasswordFormResult({ ok: false, reason: 'invalid_token' }),
+ ).toEqual({ message: 'invalid_token' })
+ })
+
+ it.each(['rate_limited', 'server_error'] as const)(
+ 'renders %s as the generic failure',
+ (reason) => {
+ expect(changePasswordFormResult({ ok: false, reason })).toEqual({
+ message: 'internal_server_error',
+ })
+ },
+ )
+})
diff --git a/apps/web/src/tests/route-namespaces.test.ts b/apps/web/src/tests/route-namespaces.test.ts
new file mode 100644
index 000000000..32b5883fb
--- /dev/null
+++ b/apps/web/src/tests/route-namespaces.test.ts
@@ -0,0 +1,304 @@
+import { readFileSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+import { HEADER_NAMESPACES } from '#/components/header'
+import { passwordResetNamespaces } from '#/lib/auth/password-reset-route'
+import { SETTINGS_NAMESPACES } from '#/lib/settings/panel'
+import { loadIntlMessages } from '#/server/messages.server'
+
+const appSrc = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const read = (path: string) => readFileSync(join(appSrc, path), 'utf8')
+
+/**
+ * The route → namespace audit, as a test rather than as a document.
+ *
+ * Three separate things have to agree for one page to render in the language
+ * its URL claims, and none of them is visible from the others:
+ *
+ * the loader ensures intlQueryOptions({ locale, namespaces })
+ * the provider reads the same options back, by the same key
+ * the Polish file carries a branch for each of those namespaces
+ *
+ * The first two disagreeing is a suspend on a key nobody warmed - a page that
+ * blanks for a round trip, or on a language switch does not repaint at all. The
+ * third missing is the quieter one, and the one that produced the Stage 9
+ * report: every screen renders, `` says `pl`, the dates are Polish,
+ * and the copy is English - which looks exactly like a broken locale runtime
+ * from the outside.
+ *
+ * The namespace lists below are the audit table. They are written out rather
+ * than imported so that changing a route's set has to be a deliberate edit
+ * here too.
+ */
+
+/** Every namespace set a migrated route declares, spelled out. */
+const ROUTES = [
+ {
+ constant: 'DISCOVER_NAMESPACES',
+ file: 'routes/_main/discover.tsx',
+ namespaces: ['core.global', 'core.search'],
+ route: '/discover',
+ },
+ {
+ constant: 'SEARCH_NAMESPACES',
+ file: 'routes/_main/search.tsx',
+ namespaces: ['core.global', 'core.search'],
+ route: '/search',
+ },
+ {
+ constant: 'LOGIN_NAMESPACES',
+ file: 'routes/login.tsx',
+ namespaces: ['core.global', 'core.auth.sign_in', 'core.auth.sso'],
+ route: '/login',
+ },
+ {
+ constant: 'REGISTER_NAMESPACES',
+ file: 'routes/register.tsx',
+ namespaces: ['core.global', 'core.auth.sign_up', 'core.auth.sso'],
+ route: '/register',
+ },
+ {
+ constant: 'CALLBACK_NAMESPACES',
+ file: 'routes/login_.sso.$providerId.tsx',
+ namespaces: ['core.global', 'core.auth.sso'],
+ route: '/login/sso/$providerId',
+ },
+ {
+ constant: 'FILES_NAMESPACES',
+ file: 'routes/_main/_authenticated/files.tsx',
+ namespaces: ['core.files', 'core.global'],
+ route: '/files',
+ },
+] as const
+
+/**
+ * `const NAME = [...] as const`, read back out of the source.
+ *
+ * These are route-local by design - a route's namespaces are nobody else's
+ * business - so there is nothing to import. Parsing them is what lets this test
+ * compare the declared set against the table above without exporting a constant
+ * purely so a test can see it.
+ */
+const declaredNamespaces = (source: string, constant: string): string[] => {
+ const match = new RegExp(
+ `const ${constant} = \\[([\\s\\S]*?)\\] as const`,
+ ).exec(source)
+
+ expect(
+ match,
+ `${constant} is declared as an \`as const\` array`,
+ ).not.toBeNull()
+
+ return [...(match?.[1] ?? '').matchAll(/'([^']+)'/g)].map(
+ ([, value]) => value,
+ )
+}
+
+describe.each(ROUTES)('$route declares one namespace set', (entry) => {
+ const source = read(entry.file)
+
+ it('declares the set this audit expects', () => {
+ expect(declaredNamespaces(source, entry.constant)).toEqual([
+ ...entry.namespaces,
+ ])
+ })
+
+ it('warms it in the loader and mounts the same constant', () => {
+ // The same identifier in both places, not two lists that happen to match:
+ // the namespace list is part of the query key, so a loader that warmed a
+ // different set warmed a key nobody reads.
+ expect(source).toContain(`namespaces: ${entry.constant},`)
+ expect(source).toContain(``)
+ })
+
+ it('always includes the global namespace', () => {
+ // `RouteMessages` mounts its provider *over* the root's rather than adding
+ // to it, so a set that omitted `core.global` would take the shell's strings
+ // away from everything below it.
+ expect(entry.namespaces).toContain('core.global')
+ })
+})
+
+/**
+ * The three routes whose set is not a route-local constant.
+ *
+ * Each has a reason: the shell's is shared with the header that reads it, the
+ * settings subtree's is shared with the breadcrumb and four panels, and
+ * password recovery's depends on which half of the flow the URL is in.
+ */
+describe('the shared namespace sets', () => {
+ it('gives the header and the shell one list', () => {
+ // The shell's loader warms `headerIntlQueryOptions`, which is built from
+ // `HEADER_NAMESPACES`, which is what `Header` reads back. One export, so a
+ // loader that warmed a different set is not expressible.
+ expect([...HEADER_NAMESPACES]).toEqual(['core.global', 'core.search'])
+ expect(read('routes/_main.tsx')).toContain('headerIntlQueryOptions({')
+ expect(read('components/header.tsx')).toContain(
+ 'useSuspenseQuery(headerIntlQueryOptions({ locale }))',
+ )
+ })
+
+ it('gives the settings layout, its panels and its breadcrumb one list', () => {
+ expect([...SETTINGS_NAMESPACES]).toEqual([
+ 'core.auth.settings',
+ 'core.global',
+ ])
+
+ for (const file of [
+ 'routes/_main/_authenticated/settings.tsx',
+ 'components/layout/settings-breadcrumb.tsx',
+ ]) {
+ expect(read(file), file).toContain('SETTINGS_NAMESPACES')
+ }
+ })
+
+ it('gives password recovery a set per mode, from one function', () => {
+ // The loader warms `passwordResetNamespaces(mode)` and returns it; the
+ // component mounts what the loader returned, so the two cannot diverge.
+ expect([...passwordResetNamespaces('request')]).toEqual([
+ 'core.global',
+ 'core.auth.sign_up',
+ 'core.auth.reset_password',
+ ])
+ expect([...passwordResetNamespaces('change')]).toEqual([
+ 'core.global',
+ 'core.auth.sign_up',
+ 'core.auth.reset_password',
+ 'core.auth.change_password',
+ ])
+
+ const source = read('routes/login_.reset-password.tsx')
+
+ expect(source).toContain('const namespaces = passwordResetNamespaces(')
+ expect(source).toContain('')
+ })
+})
+
+/** Every namespace any migrated route mounts, de-duplicated. */
+const ALL_NAMESPACES = [
+ ...new Set([
+ ...ROUTES.flatMap((entry) => entry.namespaces),
+ ...HEADER_NAMESPACES,
+ ...SETTINGS_NAMESPACES,
+ ...passwordResetNamespaces('change'),
+ ]),
+].sort((a, b) => a.localeCompare(b))
+
+/**
+ * The language switcher must not know any of this.
+ *
+ * Which sets are on screen is the cache's answer, not a list. A namespace
+ * literal appearing in the locale layer means somebody hard-coded one, and the
+ * next route to declare its own would silently stop being warmed on a switch.
+ */
+describe('the locale layer names no route namespace', () => {
+ it('keeps the switcher free of namespace literals', () => {
+ const client = read('lib/i18n/client.ts')
+
+ for (const namespace of ALL_NAMESPACES.filter(
+ (one) => one !== 'core.global',
+ )) {
+ expect(client, namespace).not.toContain(namespace)
+ }
+ })
+})
+
+/**
+ * Polish coverage, at the granularity VitNode actually promises.
+ *
+ * Per *namespace*, not per key: an incomplete translation is a supported state
+ * and falls back to English key by key. What is not supported is a namespace a
+ * migrated route renders with no Polish in it at all - that is a screen that
+ * looks untranslated, which is indistinguishable from a broken runtime.
+ */
+describe('every namespace a migrated route renders has Polish', () => {
+ const translatedLeaves = (tree: unknown): number => {
+ if (typeof tree === 'string') return 1
+ if (typeof tree !== 'object' || tree === null) return 0
+
+ return Object.values(tree).reduce(
+ (total, value) => total + translatedLeaves(value),
+ 0,
+ )
+ }
+
+ const branch = (messages: unknown, namespace: string): unknown =>
+ namespace
+ .split('.')
+ .reduce(
+ (node, key) => (node as Record | undefined)?.[key],
+ messages,
+ )
+
+ it.each(ALL_NAMESPACES)('%s', async (namespace) => {
+ const { messages } = await loadIntlMessages({
+ locale: 'pl',
+ namespaces: [namespace],
+ })
+ const pl = JSON.parse(
+ readFileSync(join(appSrc, 'locales/@vitnode/core/pl.json'), 'utf8'),
+ ) as unknown
+
+ // The merged tree always has the branch - English sits underneath it. What
+ // is being asserted is that the *override* carries one too.
+ expect(translatedLeaves(branch(messages, namespace))).toBeGreaterThan(0)
+ expect(translatedLeaves(branch(pl, namespace))).toBeGreaterThan(0)
+ })
+})
+
+/**
+ * The two canaries from the regression report, in the one place the runtime
+ * can be checked without a browser.
+ *
+ * `loadIntlMessages` is the whole server half of a route's messages: it is what
+ * the loader's server function calls, and what `RouteMessages` reads back. If
+ * these strings come out Polish here and the page renders English, the fault is
+ * in the provider tree; if they come out English here, no provider could have
+ * saved it.
+ */
+describe('the /discover and /search canaries resolve in Polish', () => {
+ it.each([
+ ['discoverTitle', 'Odkrywaj'],
+ ['discoverDesc', 'Zobacz najnowszą aktywność w społeczności.'],
+ ['loadMore', 'Wczytaj więcej'],
+ ['title', 'Szukaj'],
+ ['desc', 'Przeszukaj wszystko w społeczności.'],
+ ['sortBy', 'Sortuj według'],
+ ])('core.search.%s is "%s"', async (key, expected) => {
+ const { messages } = await loadIntlMessages({
+ locale: 'pl',
+ namespaces: ['core.global', 'core.search'],
+ })
+
+ expect(messages).toHaveProperty(`core.search.${key}`, expected)
+ })
+
+ it('translates the header nav that sits above both of them', async () => {
+ // `core.search.nav.*`, read by `Header` through `createTranslator` rather
+ // than through a provider - the shell was the visible half of the report.
+ const { messages } = await loadIntlMessages({
+ locale: 'pl',
+ namespaces: [...HEADER_NAMESPACES],
+ })
+
+ expect(messages).toHaveProperty('core.search.nav.discover', 'Odkrywaj')
+ expect(messages).toHaveProperty('core.search.nav.search', 'Szukaj')
+ })
+
+ it('leaves English exactly as it was', async () => {
+ // Adding a language may not reword the default one.
+ const { messages } = await loadIntlMessages({
+ locale: 'en',
+ namespaces: ['core.global', 'core.search'],
+ })
+
+ expect(messages).toHaveProperty('core.search.discoverTitle', 'Discover')
+ expect(messages).toHaveProperty(
+ 'core.search.discoverDesc',
+ 'See the latest activity across the community.',
+ )
+ expect(messages).toHaveProperty('core.global.login', 'Login')
+ })
+})
diff --git a/apps/web/src/tests/session-query.test.ts b/apps/web/src/tests/session-query.test.ts
index de8f0b440..9b2931634 100644
--- a/apps/web/src/tests/session-query.test.ts
+++ b/apps/web/src/tests/session-query.test.ts
@@ -1,23 +1,57 @@
-import { describe, expect, it } from 'vitest'
+import { QueryClient } from '@tanstack/react-query'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { SessionApi } from '#/lib/session'
-import { sessionQueryOptions } from '#/lib/auth/query'
import { SESSION_QUERY_KEY } from '#/lib/auth/shared'
/**
- * The canonical session query's policy, as plain options.
+ * The canonical session query's policy, and the one property of it that a route
+ * guard's correctness rests on.
*
- * 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.
+ * No render, no request and no DOM: `sessionQueryOptions()` is an object, and
+ * everything below drives a `QueryClient` held in memory with the transport
+ * stubbed. What is being exercised is this app's *reading* rules - which call
+ * consults an invalidation, and which does not - because those are the rules
+ * whose being wrong is silent.
+ */
+
+/**
+ * What the stubbed session read does next, and how often it was asked.
*
- * 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.
+ * A rejection is a *value* here rather than a `vi.fn()` reconfigured per test,
+ * because the module factory below has to be self-contained - it is hoisted
+ * above every import - and one flag is less machinery than a spy that then has
+ * to be reset.
*/
+let nextSession: SessionApi = { user: null } as SessionApi
+let nextFailure: Error | null = null
+let reads = 0
+
+vi.mock('#/lib/session', () => ({
+ getSession: async () => {
+ reads += 1
+
+ if (nextFailure) return Promise.reject(nextFailure)
+
+ return Promise.resolve(nextSession)
+ },
+}))
+
+const { ensureAuthState, invalidateSession, sessionQueryOptions } =
+ await import('#/lib/auth/query')
+
+const anonymous = { user: null } as SessionApi
+const signedIn = {
+ user: { id: 42, isAdmin: false, name: 'Test' },
+} as SessionApi
+
+beforeEach(() => {
+ nextSession = anonymous
+ nextFailure = null
+ reads = 0
+})
+
describe('the canonical session query', () => {
it('asks once and lets the failure surface', () => {
expect(sessionQueryOptions().retry).toBe(false)
@@ -27,3 +61,98 @@ describe('the canonical session query', () => {
expect(sessionQueryOptions().queryKey).toEqual(SESSION_QUERY_KEY)
})
})
+
+/**
+ * What a guard sees after a sign-in, which is the whole of this suite.
+ *
+ * The bug these pin is not hypothetical - it was live until Stage 9's review.
+ * `ensureAuthState` read through `ensureQueryData`, which returns cached data
+ * the moment any exists and consults neither staleness nor invalidation:
+ *
+ * if (cachedData !== undefined) return Promise.resolve(cachedData)
+ *
+ * so `invalidateSession()` did not, on its own, make the next guard re-read. It
+ * worked only because `invalidateQueries` ends in
+ * `refetchQueries({ type: 'active' })` and `RealtimeListeners` happens to mount
+ * an observer of that entry at the root - a component that exists for the
+ * WebSocket's sake. Every one of these tests runs with **no observers at all**,
+ * which is what makes them a test of the guard rather than of that accident.
+ */
+describe('a guard reads the session again once it has been invalidated', () => {
+ it('reads once when nothing is cached', async () => {
+ const queryClient = new QueryClient()
+
+ await ensureAuthState(queryClient)
+
+ expect(reads).toBe(1)
+ })
+
+ it('does not read again inside the stale window', async () => {
+ // The preload property `SESSION_STALE_TIME` exists for: the router runs
+ // `defaultPreload: 'intent'`, so hovering a guarded link runs its
+ // `beforeLoad`, and that must not cost a round trip per hover.
+ const queryClient = new QueryClient()
+
+ await ensureAuthState(queryClient)
+ await ensureAuthState(queryClient)
+ await ensureAuthState(queryClient)
+
+ expect(reads).toBe(1)
+ })
+
+ it('reads again after an invalidation, with nothing observing the entry', async () => {
+ const queryClient = new QueryClient()
+
+ await ensureAuthState(queryClient)
+ await invalidateSession(queryClient)
+ await ensureAuthState(queryClient)
+
+ expect(reads).toBe(2)
+ })
+
+ it('answers with the new visitor rather than the cached one', async () => {
+ // The sign-in flow, in the order `useSignInAction` performs it: the API has
+ // set the cookie, the entry is invalidated, and only then does the router
+ // move. A guard at the destination must decide on the new session.
+ const queryClient = new QueryClient()
+
+ const before = await ensureAuthState(queryClient)
+ expect(before.isAuthenticated).toBe(false)
+
+ nextSession = signedIn
+ await invalidateSession(queryClient)
+
+ const after = await ensureAuthState(queryClient)
+ expect(after.isAuthenticated).toBe(true)
+ expect(after.user?.id).toBe(42)
+ })
+
+ it('would not have, through ensureQueryData', async () => {
+ // The control, and the reason this suite exists. Without it every assertion
+ // above would pass on the implementation that had the bug - `ensureAuthState`
+ // could go back to `ensureQueryData` and only this fails.
+ const queryClient = new QueryClient()
+
+ await queryClient.ensureQueryData(sessionQueryOptions())
+ nextSession = signedIn
+ await invalidateSession(queryClient)
+
+ const stale = await queryClient.ensureQueryData(sessionQueryOptions())
+
+ expect(reads).toBe(1)
+ expect(stale.user).toBeNull()
+ })
+
+ it('rejects rather than answering when the session cannot be read', async () => {
+ // `fetchQuery` propagates, where `prefetchQuery` swallows. A guard must not
+ // be handed a stale answer during an outage - `_authenticated` leaves the
+ // rejection to the router's error path rather than signing anybody out.
+ const queryClient = new QueryClient()
+
+ nextFailure = new Error('the session could not be read')
+
+ await expect(ensureAuthState(queryClient)).rejects.toThrow(
+ 'the session could not be read',
+ )
+ })
+})
diff --git a/apps/web/src/tests/settings-routes.test.ts b/apps/web/src/tests/settings-routes.test.ts
new file mode 100644
index 000000000..1dc3fc1b0
--- /dev/null
+++ b/apps/web/src/tests/settings-routes.test.ts
@@ -0,0 +1,312 @@
+import {
+ activeSettingsNavKey,
+ isSettingsNavItemActive,
+ isSettingsRootPath,
+ SETTINGS_NAV_ITEMS,
+ SETTINGS_ROOT_HREF,
+ settingsNavHref,
+} from '@vitnode/core/views/auth/settings/settings-nav'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+import type { BreadcrumbMatch } from '#/lib/breadcrumb'
+
+import { breadcrumbOf } from '#/lib/breadcrumb'
+import { isTanStackOwnedPath } from '#/lib/migration-navigation'
+import { getRouter } from '#/router'
+
+import { withoutComments } from './source'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const settingsDir = resolve(here, '../routes/_main/_authenticated/settings')
+const layoutRoute = resolve(here, '../routes/_main/_authenticated/settings.tsx')
+
+/**
+ * The settings navigation, as data.
+ *
+ * Shared by both frameworks (`packages/vitnode/src/views/auth/settings/
+ * settings-nav.ts`), which is the reason it is worth pinning here rather than
+ * only in the package: this app's route tree has to offer exactly the panels the
+ * menu lists, and a menu entry with no route behind it is a link to a 404.
+ */
+describe('the settings navigation model', () => {
+ it('lists the three panels the settings screens have, in order', () => {
+ expect(SETTINGS_NAV_ITEMS.map((item) => item.key)).toEqual([
+ 'overview',
+ 'devices',
+ 'security',
+ ])
+ })
+
+ it('gives every item an href under the settings root', () => {
+ for (const item of SETTINGS_NAV_ITEMS) {
+ expect(item.href.startsWith(`${SETTINGS_ROOT_HREF}/`)).toBe(true)
+ }
+ })
+
+ it('mentions no locale anywhere', () => {
+ // The prefix is the router's rewrite and `MigrationLink`'s job. An href
+ // spelled `/pl/settings/...` here would be localized twice.
+ for (const item of SETTINGS_NAV_ITEMS) {
+ for (const href of [item.href, ...item.aliases]) {
+ expect(href).not.toMatch(/^\/[a-z]{2}\//)
+ }
+ }
+ })
+
+ it('answers each panel href with its own key', () => {
+ expect(activeSettingsNavKey('/settings/overview')).toBe('overview')
+ expect(activeSettingsNavKey('/settings/devices')).toBe('devices')
+ expect(activeSettingsNavKey('/settings/security')).toBe('security')
+ })
+
+ it('resolves a key back to the href the menu renders', () => {
+ for (const item of SETTINGS_NAV_ITEMS) {
+ expect(settingsNavHref(item.key)).toBe(item.href)
+ }
+ })
+
+ /**
+ * The alias, which is the whole of `/settings`' active-state behaviour: the
+ * root screen renders the overview panel, so the menu has to show *Overview*
+ * as current on it. Without this the root screen is a menu with nothing
+ * selected.
+ */
+ it('marks Overview as current on the settings root', () => {
+ expect(activeSettingsNavKey(SETTINGS_ROOT_HREF)).toBe('overview')
+ })
+
+ it('ignores a trailing slash, which is not a different page', () => {
+ expect(activeSettingsNavKey('/settings/')).toBe('overview')
+ expect(activeSettingsNavKey('/settings/security/')).toBe('security')
+ expect(isSettingsRootPath('/settings/')).toBe(true)
+ })
+
+ it('selects nothing outside the settings screens', () => {
+ expect(activeSettingsNavKey('/files')).toBeUndefined()
+ expect(activeSettingsNavKey('/')).toBeUndefined()
+ // A settings path with no menu entry - a panel reachable by URL before it is
+ // listed. Nothing selected is the honest answer.
+ expect(activeSettingsNavKey('/settings/notifications')).toBeUndefined()
+ })
+
+ it('never lights up a panel from a longer path that starts with it', () => {
+ // A prefix rule would mark Security current on a child of it, which is the
+ // same mistake `isTanStackOwnedPath` guards against for ownership.
+ expect(activeSettingsNavKey('/settings/security/sessions')).toBeUndefined()
+ })
+
+ it('is the root only at the root', () => {
+ expect(isSettingsRootPath(SETTINGS_ROOT_HREF)).toBe(true)
+ expect(isSettingsRootPath('/settings/overview')).toBe(false)
+ expect(isSettingsRootPath('/settingsx')).toBe(false)
+ })
+
+ it('marks exactly one item active on any settings path', () => {
+ for (const pathname of [
+ SETTINGS_ROOT_HREF,
+ '/settings/overview',
+ '/settings/devices',
+ '/settings/security',
+ ]) {
+ const active = SETTINGS_NAV_ITEMS.filter((item) =>
+ isSettingsNavItemActive(item, pathname),
+ )
+
+ expect(active).toHaveLength(1)
+ }
+ })
+})
+
+/**
+ * The route tree beneath the settings layout.
+ *
+ * `matchRoutes` runs no `beforeLoad`, so these paths are matched without a
+ * session. What is being asserted is the parent chain - that every panel is
+ * inside the shell, inside the session guard and inside the settings layout -
+ * rather than access.
+ */
+describe('every settings panel is a child of the layout and the guard', () => {
+ const matchedIds = (pathname: string): string[] =>
+ getRouter()
+ .matchRoutes(pathname, undefined)
+ .map((match) => match.routeId)
+
+ it.each([
+ '/settings',
+ '/settings/overview',
+ '/settings/devices',
+ '/settings/security',
+ ])(
+ '%s renders inside the shell, the guard and the settings layout',
+ (path) => {
+ expect(matchedIds(path)).toEqual(
+ expect.arrayContaining([
+ '/_main',
+ '/_main/_authenticated',
+ '/_main/_authenticated/settings',
+ ]),
+ )
+ },
+ )
+
+ /**
+ * Every destination the menu offers is one this app renders itself.
+ *
+ * This is what "the settings navigation is ordinary owned-route navigation"
+ * amounts to, stated as a property rather than as a choice of component. The
+ * menu is handed `MigrationLink`, which asks the route tree per href and does a
+ * full document load into the Next.js app for anything this one does not serve
+ * - correct behaviour, and invisible when it happens. With every Stage 9 panel
+ * migrated the answer should now be "owned" for all of them, so this fails if a
+ * menu entry is added without a route behind it, or if a panel's route is moved
+ * out from under the layout.
+ */
+ it.each(SETTINGS_NAV_ITEMS)(
+ 'the $key menu entry is a client-side navigation',
+ ({ href }) => {
+ expect(isTanStackOwnedPath(getRouter(), href)).toBe(true)
+ },
+ )
+
+ it('and so is the settings root the menu falls back to', () => {
+ expect(isTanStackOwnedPath(getRouter(), SETTINGS_ROOT_HREF)).toBe(true)
+ })
+
+ /**
+ * The alias, at the level of the route tree: `/settings` is served by the
+ * layout's *index* child rather than by a redirect, so it is a page in its own
+ * right and the deepest match consumes the whole path.
+ */
+ it('serves the settings root from an index child, not a redirect', () => {
+ expect(matchedIds('/settings').at(-1)).toBe(
+ '/_main/_authenticated/settings/',
+ )
+ expect(withoutComments(`${settingsDir}/index.tsx`)).not.toContain(
+ 'redirect',
+ )
+ })
+
+ it('renders the same panel component at the root and at /settings/overview', () => {
+ // The visible half of the alias. Two routes, one component - so the two URLs
+ // cannot drift into two different overview screens.
+ for (const file of ['index.tsx', 'overview.tsx']) {
+ expect(withoutComments(`${settingsDir}/${file}`)).toContain(
+ 'OverviewSettings',
+ )
+ }
+ })
+})
+
+/**
+ * What the panels do *not* do, which in this subtree is most of it.
+ *
+ * The frame, the session check and the robots directive all belong to exactly one
+ * route, and a panel that quietly acquired its own copy of any of them would keep
+ * working while the two copies drifted. A source scan is the honest way to pin
+ * "this file does not contain that", and `withoutComments` is what stops the
+ * prose above each route - which discusses every one of these by name in order to
+ * say where it really lives - from matching.
+ */
+describe('a settings panel owns only its own contents', () => {
+ const panels = ['index.tsx', 'overview.tsx', 'security.tsx', 'devices.tsx']
+
+ it.each(panels)('%s adds no session check of its own', (file) => {
+ const code = withoutComments(`${settingsDir}/${file}`)
+
+ expect(code).not.toContain('ensureAuthState')
+ expect(code).not.toContain('getSession')
+ expect(code).not.toContain('RequireSession')
+ })
+
+ it.each(panels)('%s does not restate the robots directive', (file) => {
+ // The layout declares `noindex, nofollow` once and TanStack Router merges
+ // the `head` of every matched route, so the subtree inherits it.
+ expect(withoutComments(`${settingsDir}/${file}`)).not.toContain('robots')
+ })
+
+ it.each(panels)('%s does not render the shell a second time', (file) => {
+ const code = withoutComments(`${settingsDir}/${file}`)
+
+ expect(code).not.toContain('SettingsShellContent')
+ expect(code).not.toContain('SettingsNavContent')
+ })
+
+ it('declares the robots directive exactly once, on the layout', () => {
+ expect(withoutComments(layoutRoute)).toContain("name: 'robots'")
+ })
+
+ it('puts the session guard nowhere in the subtree', () => {
+ expect(withoutComments(layoutRoute)).not.toContain('ensureAuthState')
+ })
+})
+
+/**
+ * The breadcrumb, as the data each route declares rather than as rendered markup.
+ *
+ * `breadcrumbOf` is already covered in `main-shell.test.ts`; what is new here is
+ * that this is the first subtree to use it for a *nested* trail, so the question
+ * worth asking is which route declares what.
+ */
+describe('the settings breadcrumb is declared by routes, deepest first', () => {
+ const matched = (pathname: string): BreadcrumbMatch[] =>
+ getRouter().matchRoutes(pathname, undefined)
+
+ /**
+ * How many of the matched routes declared a crumb at all.
+ *
+ * Counted rather than collected: `React.ReactNode` includes a promise in React
+ * 19's types, so a helper that *returned* the declarations reads as an async
+ * function to every rule that scans for one.
+ */
+ const declaringMatches = (pathname: string): number =>
+ matched(pathname).filter(
+ (match) => match.staticData.breadcrumb !== undefined,
+ ).length
+
+ /**
+ * The crumb the shell would render, as the element it is.
+ *
+ * Typed as the props this suite reads rather than as `React.ReactNode`: that
+ * type includes a promise in React 19, which makes every function returning
+ * one look like an async component to the rules that scan for them.
+ */
+ const crumbOf = (pathname: string): { props: { navKey?: string } } =>
+ breadcrumbOf(matched(pathname)) as { props: { navKey?: string } }
+
+ it('gives the settings root the layout’s own single crumb', () => {
+ // The index route declares nothing, so the trail falls through to the
+ // layout's - which is what the Next.js `@breadcrumb/settings` slot renders.
+ expect(declaringMatches('/settings')).toBe(1)
+ })
+
+ it.each(['/settings/overview', '/settings/security', '/settings/devices'])(
+ '%s declares its own trail, which wins by being deeper',
+ (pathname) => {
+ expect(declaringMatches(pathname)).toBe(2)
+ },
+ )
+
+ /**
+ * The whole subtree, as the crumb each URL actually resolves to.
+ *
+ * The label comes from the navigation model rather than from a pathname
+ * registry, so what a route declares is the key it already uses for its own
+ * tab title - and `undefined` is the root's answer rather than a missing one,
+ * because the layout's crumb is the single "Settings" trail.
+ *
+ * Stated as one table over all four URLs because this is the seam: the layout,
+ * two panels and the devices panel were written separately, and a crumb that
+ * resolved to the wrong depth would look right on whichever page its author
+ * was reading.
+ */
+ it.each([
+ ['/settings', undefined],
+ ['/settings/overview', 'overview'],
+ ['/settings/devices', 'devices'],
+ ['/settings/security', 'security'],
+ ] as const)('%s resolves to the %s crumb', (pathname, navKey) => {
+ expect(crumbOf(pathname).props.navKey).toBe(navKey)
+ })
+})
diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
index 829d8ef80..06b619502 100644
--- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
+++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
@@ -35,6 +35,15 @@ export const changePasswordRoute = buildRoute({
201: {
description: "Password changed",
},
+ 400: {
+ // Thrown by the handler below when the `userId` + `token` +
+ // unexpired-`expiresAt` lookup finds nothing - a wrong link, a spent one,
+ // or one older than thirty minutes. Declared so the status is part of the
+ // route's contract rather than an undocumented throw a client has to
+ // discover: `fetcher()` types `res.status` from this list, so a caller
+ // cannot branch on a status the route does not admit to.
+ description: "Invalid or expired token",
+ },
},
},
handler: async c => {
diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
index 40f9d75c2..6511dc916 100644
--- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
+++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import {
AlertDialog,
diff --git a/packages/vitnode/src/components/confirm-action/content.tsx b/packages/vitnode/src/components/confirm-action/content.tsx
index 7ae15acee..871933f1a 100644
--- a/packages/vitnode/src/components/confirm-action/content.tsx
+++ b/packages/vitnode/src/components/confirm-action/content.tsx
@@ -1,5 +1,5 @@
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import {
AlertDialogCancel,
diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx
index 16c6d5790..e4745fd2d 100644
--- a/packages/vitnode/src/components/form/auto-form.tsx
+++ b/packages/vitnode/src/components/form/auto-form.tsx
@@ -2,7 +2,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useAnimate, useReducedMotion } from "motion/react";
-import { useTranslations } from "next-intl";
import { useEffect } from "react";
import {
type ControllerRenderProps,
@@ -14,6 +13,7 @@ import {
type UseFormReturn,
useFormState,
} from "react-hook-form";
+import { useTranslations } from "use-intl";
import z from "zod";
import type { routeMiddlewareSchema } from "../../api/modules/middleware/route";
diff --git a/packages/vitnode/src/components/form/common/label.tsx b/packages/vitnode/src/components/form/common/label.tsx
index 7f0bc66a2..8c340cb04 100644
--- a/packages/vitnode/src/components/form/common/label.tsx
+++ b/packages/vitnode/src/components/form/common/label.tsx
@@ -1,4 +1,4 @@
-import { useTranslations } from "next-intl";
+import { useTranslations } from "use-intl";
import { FieldLabel } from "@/components/ui/field";
import { useFormField } from "@/components/ui/form";
diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx
index 7c6c94781..3513dfbab 100644
--- a/packages/vitnode/src/components/form/fields/multi-lang.tsx
+++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx
@@ -2,8 +2,8 @@
import type { ControllerRenderProps, FieldValues } from "react-hook-form";
-import { useLocale, useTranslations } from "next-intl";
import React from "react";
+import { useLocale, useTranslations } from "use-intl";
import type { MultiLangValue } from "@/lib/helpers/multi-lang";
import type { LocaleConfig } from "@/vitnode.config";
diff --git a/packages/vitnode/src/components/switchers/themes/theme-switcher.tsx b/packages/vitnode/src/components/switchers/themes/theme-switcher.tsx
index 000a689c0..f2c758a5f 100644
--- a/packages/vitnode/src/components/switchers/themes/theme-switcher.tsx
+++ b/packages/vitnode/src/components/switchers/themes/theme-switcher.tsx
@@ -1,7 +1,7 @@
"use client";
import { Moon, Sun } from "lucide-react";
-import { useTranslations } from "next-intl";
+import { useTranslations } from "use-intl";
import { useTheme } from "../../theme-provider";
import { Button } from "../../ui/button";
diff --git a/packages/vitnode/src/components/table/content.tsx b/packages/vitnode/src/components/table/content.tsx
index 75734df46..53c72d7e7 100644
--- a/packages/vitnode/src/components/table/content.tsx
+++ b/packages/vitnode/src/components/table/content.tsx
@@ -1,5 +1,4 @@
import { SearchXIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import type {
AlignDataTable,
@@ -18,6 +17,7 @@ import {
TableRow,
} from "../ui/table";
import { FiltersDataTable } from "./filters";
+import { NoResultsDataTable } from "./no-results";
import { OrderTableHeadDataTable } from "./order-table-head";
import { PaginationDataTable } from "./pagination";
import { SearchDataTable } from "./search";
@@ -47,7 +47,6 @@ export function ContentDataTable({
filters,
...props
}: DataTableProps) {
- const t = useTranslations("core.global");
const hasToolbar = Boolean(search) || Boolean(filters?.length);
const allColumns: ColumnDef[] = bulkActions
? [
@@ -149,12 +148,10 @@ export function ContentDataTable({
{customNoResults?.icon ?? }
-
- {customNoResults?.title ?? t("no_results.title")}
-
-
- {customNoResults?.description ?? t("no_results.desc")}
-
+
{customNoResults?.footer}
diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx
index ee583a92c..fff2d9616 100644
--- a/packages/vitnode/src/components/table/filters.tsx
+++ b/packages/vitnode/src/components/table/filters.tsx
@@ -1,9 +1,9 @@
"use client";
import { CheckIcon, PlusCircleIcon, Trash2 } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
+import { useTranslations } from "use-intl";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/components/table/no-results.tsx b/packages/vitnode/src/components/table/no-results.tsx
new file mode 100644
index 000000000..3862c796f
--- /dev/null
+++ b/packages/vitnode/src/components/table/no-results.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useTranslations } from "use-intl";
+
+/**
+ * The data table's default empty state.
+ *
+ * Two strings, and its own `"use client"` module for one reason:
+ * {@link ContentDataTable} is rendered as a *Server Component* by every AdminCP
+ * page - `DataTable` has no client boundary of its own, so React renders the
+ * table on the server and only its controls in the browser - and as an ordinary
+ * client component by `apps/web`, which has no server components at all. It is
+ * therefore the one shared component in this package that cannot read a React
+ * context, because in half its callers there is no context to read.
+ *
+ * `next-intl` used to paper over that: its root entry resolves to an
+ * RSC-capable `useTranslations` under Next's `react-server` condition and to
+ * the context-reading one everywhere else. That works, and it is the only
+ * reason the table translated in both places - but it is also the last thing
+ * tying a shared component to Next.js, and it hid the fact that the table
+ * renders in two different environments.
+ *
+ * So the translating moved here instead, behind a boundary that is a client
+ * component in both frameworks. A caller that already has the copy passes
+ * `customNoResults` and this renders its strings without looking anything up.
+ */
+export const NoResultsDataTable = ({
+ description,
+ title,
+}: {
+ description?: string;
+ title?: string;
+}) => {
+ const t = useTranslations("core.global.no_results");
+
+ return (
+ <>
+
+ {title ?? t("title")}
+
+
+ {description ?? t("desc")}
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/components/table/pagination.tsx b/packages/vitnode/src/components/table/pagination.tsx
index f97795376..969150178 100644
--- a/packages/vitnode/src/components/table/pagination.tsx
+++ b/packages/vitnode/src/components/table/pagination.tsx
@@ -1,8 +1,8 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import { Button } from "../ui/button";
import {
diff --git a/packages/vitnode/src/components/table/search.tsx b/packages/vitnode/src/components/table/search.tsx
index eb096ff45..65afcf6b6 100644
--- a/packages/vitnode/src/components/table/search.tsx
+++ b/packages/vitnode/src/components/table/search.tsx
@@ -1,9 +1,9 @@
"use client";
import { Search } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
+import { useTranslations } from "use-intl";
import {
InputGroup,
diff --git a/packages/vitnode/src/components/table/selection.tsx b/packages/vitnode/src/components/table/selection.tsx
index 2c60fac79..3f651e65b 100644
--- a/packages/vitnode/src/components/table/selection.tsx
+++ b/packages/vitnode/src/components/table/selection.tsx
@@ -2,9 +2,9 @@
import { XIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
-import { useTranslations } from "next-intl";
import React from "react";
import { createPortal } from "react-dom";
+import { useTranslations } from "use-intl";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
diff --git a/packages/vitnode/src/components/ui/alert-dialog.tsx b/packages/vitnode/src/components/ui/alert-dialog.tsx
index a3b00eedc..59a251ddc 100644
--- a/packages/vitnode/src/components/ui/alert-dialog.tsx
+++ b/packages/vitnode/src/components/ui/alert-dialog.tsx
@@ -1,8 +1,8 @@
"use client";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/components/ui/button-client.tsx b/packages/vitnode/src/components/ui/button-client.tsx
index 3f524217e..b9049d46f 100644
--- a/packages/vitnode/src/components/ui/button-client.tsx
+++ b/packages/vitnode/src/components/ui/button-client.tsx
@@ -2,7 +2,7 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { AnimatePresence, motion } from "motion/react";
-import { useTranslations } from "next-intl";
+import { useTranslations } from "use-intl";
import { cn } from "../../lib/utils";
import { type ButtonProps, buttonVariants } from "./button";
diff --git a/packages/vitnode/src/components/ui/dialog.tsx b/packages/vitnode/src/components/ui/dialog.tsx
index 09048f086..a47e4ddd8 100644
--- a/packages/vitnode/src/components/ui/dialog.tsx
+++ b/packages/vitnode/src/components/ui/dialog.tsx
@@ -2,8 +2,8 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { XIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/components/ui/form.tsx b/packages/vitnode/src/components/ui/form.tsx
index 4bef7f1c1..b1c600a44 100644
--- a/packages/vitnode/src/components/ui/form.tsx
+++ b/packages/vitnode/src/components/ui/form.tsx
@@ -2,7 +2,6 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
-import { useTranslations } from "next-intl";
import React from "react";
import {
Controller,
@@ -14,6 +13,7 @@ import {
useFormContext,
useFormState,
} from "react-hook-form";
+import { useTranslations } from "use-intl";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/lib/api/get-devices-api.ts b/packages/vitnode/src/lib/api/get-devices-api.ts
deleted file mode 100644
index 7f1ea391a..000000000
--- a/packages/vitnode/src/lib/api/get-devices-api.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { usersModule } from "@/api/modules/users/users.module";
-import { fetcher } from "@/lib/fetcher";
-
-export const getDevicesApi = async () => {
- const res = await fetcher(usersModule, {
- path: "/devices",
- method: "get",
- module: "users",
- });
-
- const data = await res.json();
-
- return data;
-};
-
-export type DevicesApi = Awaited>;
diff --git a/packages/vitnode/src/lib/i18n/rsc-boundaries.test.ts b/packages/vitnode/src/lib/i18n/rsc-boundaries.test.ts
new file mode 100644
index 000000000..c25385c3f
--- /dev/null
+++ b/packages/vitnode/src/lib/i18n/rsc-boundaries.test.ts
@@ -0,0 +1,200 @@
+// @vitest-environment node
+import { existsSync, readdirSync, 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, "../..");
+const repoRoot = resolve(srcRoot, "../../..");
+
+/**
+ * Where a shared component may read `use-intl`, and where it may not.
+ *
+ * `useTranslations` from `use-intl` is a React context read, and a React Server
+ * Component has no context. `next-intl`'s root entry hides that difference - it
+ * resolves to an RSC-capable implementation under Next's `react-server`
+ * condition and to the context-reading one everywhere else - so a component
+ * that reads through it translates in both environments without anybody having
+ * to know which one it is in.
+ *
+ * That is convenient and it is exactly the thing this migration is removing:
+ * every component `apps/web` renders now imports `use-intl` directly, because
+ * TanStack Start has no `react-server` condition and the `next-intl` root entry
+ * was the last Next.js dependency in the shared tree.
+ *
+ * The trade is that the environment now matters, and `ContentDataTable` is the
+ * component that proves it: `DataTable` mounts no client boundary of its own,
+ * so React renders the AdminCP's table *on the server* while `apps/web` renders
+ * the same component in the browser. Swapping its `next-intl` import for
+ * `use-intl` compiled, type-checked, passed every test in this repository and
+ * broke every AdminCP table - which is why the check is here rather than in a
+ * reviewer's head. Its two strings now live in `NoResultsDataTable`, behind
+ * `"use client"`.
+ *
+ * The rule, then: **a module React renders on the server may not read
+ * `use-intl`.** It may take its copy as a prop, or delegate to a client leaf
+ * that reads it.
+ */
+
+const SKIP_DIRECTORIES = new Set([
+ ".next",
+ ".output",
+ ".source",
+ ".turbo",
+ "dist",
+ "node_modules",
+]);
+
+/** Next's own file conventions - every module React can render from. */
+const ENTRY_FILE =
+ /\/(page|layout|template|route|not-found|error|global-error|default|loading|opengraph-image|sitemap|robots)\.tsx?$/;
+
+const filesUnder = (directory: string): string[] => {
+ if (!existsSync(directory)) return [];
+
+ const entries: string[] = [];
+
+ for (const name of readdirSync(directory)) {
+ const path = join(directory, name);
+
+ if (statSync(path).isDirectory()) {
+ if (!SKIP_DIRECTORIES.has(name)) entries.push(...filesUnder(path));
+ continue;
+ }
+
+ if (
+ /\.tsx?$/.test(name) &&
+ !name.endsWith(".d.ts") &&
+ !/\.test\.tsx?$/.test(name)
+ ) {
+ entries.push(path);
+ }
+ }
+
+ return entries;
+};
+
+const isClientModule = (path: string): boolean =>
+ /^\s*["']use client["']/.test(readFileSync(path, "utf8"));
+
+/**
+ * Every specifier a file imports at runtime.
+ *
+ * `import type` is stripped first: the compiler erases it, so it is not part of
+ * the graph React renders.
+ */
+const importsFrom = (path: string): string[] => {
+ const source = readFileSync(path, "utf8");
+
+ return [
+ ...source.matchAll(
+ /(?:^|[^\w$.])from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']/g,
+ ),
+ ]
+ .filter(match => {
+ const before = source.slice(
+ Math.max(0, (match.index ?? 0) - 220),
+ match.index,
+ );
+ const statement = before.lastIndexOf("import");
+
+ return (
+ statement === -1 || !/^import\s+type\b/.test(before.slice(statement))
+ );
+ })
+ .map(match => match[1] ?? match[2])
+ .filter((specifier): specifier is string => Boolean(specifier));
+};
+
+const resolveSpecifier = (specifier: string, from: string): null | string => {
+ let base: string;
+
+ if (specifier.startsWith("@/")) base = join(srcRoot, specifier.slice(2));
+ else if (specifier.startsWith("@vitnode/core/")) {
+ base = join(srcRoot, specifier.slice("@vitnode/core/".length));
+ } 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 null;
+};
+
+/**
+ * Every module React renders on the server, and the entry that reaches it.
+ *
+ * Walks out from each Next entry point and **stops at every `"use client"`
+ * boundary** - which is precisely React's own rule for what runs where.
+ */
+const serverRenderedModules = (): Map => {
+ const entries = [
+ ...filesUnder(join(srcRoot, "routes")),
+ ...filesUnder(join(repoRoot, "apps/docs/src")),
+ ...filesUnder(join(repoRoot, "plugins/blog/src/routes")),
+ ...filesUnder(join(repoRoot, "plugins/example/src/routes")),
+ ].filter(path => ENTRY_FILE.test(path) && !isClientModule(path));
+
+ const reached = new Map();
+ const stack: { entry: string; module: string }[] = entries.map(entry => ({
+ entry,
+ module: entry,
+ }));
+
+ for (let next = stack.pop(); next; next = stack.pop()) {
+ const { entry, module } = next;
+ if (reached.has(module)) continue;
+ reached.set(module, entry);
+
+ for (const specifier of importsFrom(module)) {
+ const target = resolveSpecifier(specifier, module);
+ if (target && !isClientModule(target))
+ stack.push({ entry, module: target });
+ }
+ }
+
+ return reached;
+};
+
+describe("the server-rendered half of the package never reads a React context", () => {
+ const modules = serverRenderedModules();
+
+ it("finds the Next.js entry points it is walking from", () => {
+ // Every assertion below is a "found nothing" one, which a walk that reached
+ // nothing also satisfies.
+ expect(modules.size).toBeGreaterThan(100);
+ expect(
+ [...modules.keys()].some(path =>
+ path.endsWith("components/table/content.tsx"),
+ ),
+ "the AdminCP tables reach ContentDataTable on the server",
+ ).toBe(true);
+ });
+
+ it("stops at every client boundary", () => {
+ // The control: `AutoForm` is `"use client"`, so nothing below it is server
+ // rendered even though a Server Component page renders one.
+ expect(
+ [...modules.keys()].filter(path =>
+ path.endsWith("components/form/auto-form.tsx"),
+ ),
+ ).toEqual([]);
+ });
+
+ it("reads use-intl from nowhere React renders on the server", () => {
+ const offenders = [...modules.entries()]
+ .filter(([path]) =>
+ /(?:^|[^\w$.])from\s*["']use-intl["']/.test(readFileSync(path, "utf8")),
+ )
+ .map(
+ ([path, entry]) =>
+ `${relative(repoRoot, path)} (rendered by ${relative(repoRoot, entry)})`,
+ );
+
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/packages/vitnode/src/views/auth/auth-boundaries.test.ts b/packages/vitnode/src/views/auth/auth-boundaries.test.ts
index 2a7ff9a9d..5ea42e14b 100644
--- a/packages/vitnode/src/views/auth/auth-boundaries.test.ts
+++ b/packages/vitnode/src/views/auth/auth-boundaries.test.ts
@@ -17,9 +17,27 @@ const srcRoot = resolve(here, "../..");
* visible until somebody tries.
*/
const SHARED = {
+ breadcrumbTrail: join(here, "../breadcrumb/breadcrumb-main-content.tsx"),
card: join(here, "sign-in/sign-in-content.tsx"),
+ changePasswordForm: join(
+ here,
+ "password-reset/change-password-form/change-password-form-content.tsx",
+ ),
errorScreen: join(here, "../error/error-content.tsx"),
+ passwordResetCard: join(here, "password-reset/password-reset-content.tsx"),
+ passwordResetForm: join(
+ here,
+ "password-reset/form/password-reset-form-content.tsx",
+ ),
+ recoveryLink: join(here, "password-reset/recovery-link.ts"),
+ settingsNav: join(here, "settings/nav-content.tsx"),
+ settingsNavModel: join(here, "settings/settings-nav.ts"),
+ settingsOverview: join(here, "settings/overview/overview.tsx"),
+ settingsSecurity: join(here, "settings/security/security.tsx"),
+ settingsShell: join(here, "settings/shell-content.tsx"),
signInForm: join(here, "sign-in/form/sign-in-form-content.tsx"),
+ signUpCard: join(here, "sign-up/sign-up-content.tsx"),
+ signUpForm: join(here, "sign-up/form/sign-up-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"),
@@ -27,8 +45,18 @@ const SHARED = {
/** The Next.js half: server actions, `next/cache`, locale-aware navigation. */
const NEXT_WRAPPERS = {
+ breadcrumbTrail: join(here, "../breadcrumb/breadcrumb-main.tsx"),
card: join(here, "sign-in/sign-in-card.tsx"),
+ changePasswordForm: join(
+ here,
+ "password-reset/change-password-form/form.tsx",
+ ),
+ passwordResetForm: join(here, "password-reset/form/form.tsx"),
+ settingsNav: join(here, "settings/nav.tsx"),
+ settingsShell: join(here, "settings/shell.tsx"),
signInForm: join(here, "sign-in/form/form.tsx"),
+ signUpCard: join(here, "sign-up/sign-up-card.tsx"),
+ signUpForm: join(here, "sign-up/form/form.tsx"),
ssoButtons: join(here, "sso/buttons/client.tsx"),
ssoCallback: join(here, "sso/callback/client/client.tsx"),
};
@@ -213,11 +241,50 @@ describe("the shared views take their framework parts as props", () => {
});
it("takes its links as a component in every view that renders one", () => {
- for (const path of [SHARED.card, SHARED.signInForm, SHARED.ssoCallback]) {
+ for (const path of [
+ SHARED.card,
+ SHARED.signInForm,
+ SHARED.signUpCard,
+ SHARED.signUpForm,
+ SHARED.ssoCallback,
+ ]) {
expect(withoutComments(path)).toContain("LinkComponent");
}
});
+ it("asks for a sign-up callback rather than calling a mutation", () => {
+ const code = withoutComments(SHARED.signUpForm);
+
+ expect(code).toContain("onSignUp");
+ expect(code).not.toContain("mutationApi");
+ });
+
+ it("asks for the two recovery mutations as callbacks", () => {
+ expect(withoutComments(SHARED.passwordResetForm)).toContain(
+ "onRequestReset",
+ );
+ expect(withoutComments(SHARED.changePasswordForm)).toContain(
+ "onChangePassword",
+ );
+ });
+
+ it("takes where to go after a password change as a callback", () => {
+ // The API mints no session on a password change, so the visitor goes to the
+ // login page - but `useRouter().replace` is Next-only and the router
+ // navigation is TanStack-only, so the trip itself is the caller's.
+ const code = withoutComments(SHARED.changePasswordForm);
+
+ expect(code).toContain("onChanged");
+ expect(code).not.toContain("useRouter");
+ });
+
+ it("takes an already-parsed recovery link rather than raw search params", () => {
+ const code = withoutComments(SHARED.changePasswordForm);
+
+ expect(code).toContain("link: RecoveryLink;");
+ expect(code).not.toContain("userId: string");
+ });
+
it("renders the callback from a state rather than owning the request", () => {
const code = withoutComments(SHARED.ssoCallback);
@@ -250,15 +317,92 @@ describe("the Next wrappers keep the Next-only pieces", () => {
});
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);
+ for (const path of [
+ NEXT_WRAPPERS.changePasswordForm,
+ NEXT_WRAPPERS.passwordResetForm,
+ NEXT_WRAPPERS.signInForm,
+ NEXT_WRAPPERS.signUpForm,
+ NEXT_WRAPPERS.ssoCallback,
+ ]) {
+ expect(
+ runtimeImports(path).some(one => one.includes("mutation-api.server")),
+ ).toBe(true);
+ }
+ });
+});
+
+/**
+ * The settings screens, split the same way.
+ *
+ * `SettingsShell` was visually reusable and structurally Next-only: it read
+ * `usePathname` to decide the narrow-screen behaviour, it imported `next-intl`'s
+ * `Link` for the back link, and it imported the navigation, which read the same
+ * pathname a second time for the active item. Three separate reasons a TanStack
+ * Start layout route could not render it, and none of them visible in what it
+ * looks like.
+ *
+ * What replaced them is one rule: the frame and the menu are *told* where the
+ * visitor is and how to build a link. The assertions below are about that shape
+ * as well as about the absence of a specifier, because a shared component can
+ * also fail by taking the wrong thing as a prop.
+ */
+describe("the settings frame is told its framework parts", () => {
+ const withoutComments = (path: string): string =>
+ readFileSync(path, "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/\/\/.*$/gm, "");
+
+ it("takes the navigation as a slot and the back link as a component", () => {
+ const code = withoutComments(SHARED.settingsShell);
+
+ expect(code).toContain("nav: React.ReactNode;");
+ expect(code).toContain("BackLink: AuthLinkComponent;");
+ });
+
+ it("takes where it is as a prop rather than asking", () => {
+ // The one decision neither half can make for itself. `isSettingsRootPath`
+ // and the active-item rule are shared; reading the pathname is not.
+ for (const path of [SHARED.settingsShell, SHARED.settingsNav]) {
+ expect(withoutComments(path)).not.toContain("usePathname");
+ }
+
+ expect(withoutComments(SHARED.settingsShell)).toContain("isRoot: boolean;");
+ expect(withoutComments(SHARED.settingsNav)).toContain("pathname: string;");
+ });
+
+ it("takes its links as a component in the menu and in the breadcrumb", () => {
+ for (const path of [SHARED.settingsNav, SHARED.breadcrumbTrail]) {
+ expect(withoutComments(path)).toContain("LinkComponent");
+ }
+ });
+
+ it("keeps the menu and the active-item rule as data, not markup", () => {
+ // `settings-nav.ts` is what both frameworks agree through, so it must stay
+ // free of React as well as of Next: a model that rendered would be a third
+ // navigation nobody meant to have.
+ const reached = [...externalGraph(SHARED.settingsNavModel).keys()];
+
+ expect(reached).not.toContain("react");
+ expect(reached.some(one => one.includes("intl"))).toBe(false);
+ expect(withoutComments(SHARED.settingsNav)).toContain("settings-nav");
+ });
+
+ it("reads its strings from use-intl rather than from a request", () => {
+ // The two panels were Server Components calling `getTranslations`, which is
+ // what made a heading Next-only. The scans above pin the absence of that;
+ // this pins what took its place, so a panel cannot pass by translating
+ // nothing at all.
+ for (const path of [SHARED.settingsOverview, SHARED.settingsSecurity]) {
+ expect(runtimeImports(path)).toContain("use-intl");
+ }
+ });
+
+ it("is the Next wrappers that know where the visitor is", () => {
+ for (const path of [
+ NEXT_WRAPPERS.settingsNav,
+ NEXT_WRAPPERS.settingsShell,
+ ]) {
+ expect(withoutComments(path)).toContain("usePathname");
+ }
});
});
diff --git a/packages/vitnode/src/views/auth/auth-link.ts b/packages/vitnode/src/views/auth/auth-link.ts
index e91933393..293b7a27b 100644
--- a/packages/vitnode/src/views/auth/auth-link.ts
+++ b/packages/vitnode/src/views/auth/auth-link.ts
@@ -34,9 +34,13 @@ export type AuthLinkComponent = (props: AuthLinkProps) => React.ReactNode;
*
* 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.
+ * about it.
+ *
+ * Nothing here records which application serves any of them either, and that is
+ * the point rather than an omission. All three were Next.js pages when this was
+ * written and all three are TanStack Start routes now; in that app they are
+ * reached through the migration link, which asks the route tree per href, so the
+ * change was route files and no edit to this record.
*/
export const AUTH_HREF = {
resetPassword: "/login/reset-password",
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx b/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx
new file mode 100644
index 000000000..4938f6351
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useTranslations } from "use-intl";
+
+import { AutoForm } from "@/components/form/auto-form";
+import {
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+import type { RecoveryLink } from "../recovery-link";
+
+import { PasswordInput } from "../../sign-up/components/password-input";
+import {
+ type ChangePasswordSubmit,
+ useChangePasswordForm,
+} from "./use-change-password-form";
+
+export type { ChangePasswordSubmit };
+
+/**
+ * The second half of password recovery - shared.
+ *
+ * One field, and two props that are the framework boundary: the mutation, and
+ * what to do once the password has changed. The form no longer imports a server
+ * action or `@/lib/navigation`, so a TanStack Start route renders exactly the
+ * card the Next.js page renders.
+ *
+ * `link` is already parsed - see `../recovery-link.ts`. A route that could not
+ * parse one must render the request form instead, which is a decision for the
+ * page rather than for this component: there is no such thing as this screen
+ * without a link.
+ *
+ * No captcha: the API's change-password route does not ask for one
+ * (`withCaptcha` is absent), because the token in the link is the thing being
+ * checked.
+ */
+export const ChangePasswordFormContent = ({
+ link,
+ onChanged,
+ onChangePassword,
+}: {
+ link: RecoveryLink;
+ onChanged: () => void;
+ onChangePassword: ChangePasswordSubmit;
+}) => {
+ const t = useTranslations("core.auth.change_password");
+ const tSignUp = useTranslations("core.auth.sign_up");
+ const { formSchema, onSubmit } = useChangePasswordForm({
+ link,
+ onChanged,
+ onChangePassword,
+ });
+
+ return (
+ <>
+
+
+ {t("title")}
+
+ {t("desc")}
+
+
+
+ (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={onSubmit}
+ submitButtonProps={{
+ className: "w-full",
+ children: t("submit"),
+ }}
+ />
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/form.tsx b/packages/vitnode/src/views/auth/password-reset/change-password-form/form.tsx
index 715270be3..d8e8e3cc9 100644
--- a/packages/vitnode/src/views/auth/password-reset/change-password-form/form.tsx
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/form.tsx
@@ -1,53 +1,31 @@
"use client";
-import { useTranslations } from "next-intl";
+import { useRouter } from "@/lib/navigation";
-import { AutoForm } from "@/components/form/auto-form";
-import {
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
+import type { RecoveryLink } from "../recovery-link";
-import { PasswordInput } from "../../sign-up/components/password-input";
-import { useForm } from "./use-form";
+import { AUTH_HREF } from "../../auth-link";
+import { ChangePasswordFormContent } from "./change-password-form-content";
+import { mutationApi } from "./mutation-api.server";
-export const ChangePasswordForm = (props: {
- token: string;
- userId: string;
-}) => {
- const t = useTranslations("core.auth.change_password");
- const tSignUp = useTranslations("core.auth.sign_up");
- const { formSchema, onSubmit } = useForm(props);
+/**
+ * {@link ChangePasswordFormContent}, wired to Next.js.
+ *
+ * Two props, both Next-only: the server action, and `next-intl`'s locale-aware
+ * `replace` for the trip to the login page once the password has changed. The
+ * API mints no session on that change, so leaving for the login form is the
+ * whole of the success path.
+ */
+export const ChangePasswordForm = ({ link }: { link: RecoveryLink }) => {
+ const { replace } = useRouter();
return (
- <>
-
-
- {t("title")}
-
- {t("desc")}
-
-
-
- (
-
- ),
- },
- ]}
- formSchema={formSchema}
- onSubmit={onSubmit}
- submitButtonProps={{
- className: "w-full",
- children: t("submit"),
- }}
- />
-
- >
+ {
+ replace(AUTH_HREF.signIn);
+ }}
+ onChangePassword={mutationApi}
+ />
);
};
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/mutation-api.server.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/mutation-api.server.ts
index e47effa9c..ba2969a6a 100644
--- a/packages/vitnode/src/views/auth/password-reset/change-password-form/mutation-api.server.ts
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/mutation-api.server.ts
@@ -1,17 +1,30 @@
"use server";
-import type z from "zod";
-
-import type { zodChangePasswordSchema } from "@/api/modules/users/routes/change-password.route";
-
import { usersModule } from "@/api/modules/users/users.module";
import { fetcher } from "@/lib/fetcher";
+import type {
+ ChangePasswordMutationResult,
+ ChangePasswordSubmitValues,
+} from "./schema";
+
+/**
+ * Setting a new password from a recovery link, for Next.js.
+ *
+ * `400` is kept apart from everything else: it is the API's answer when the
+ * `userId` + `token` + unexpired-`expiresAt` lookup finds nothing, which means
+ * the link is wrong, spent or older than thirty minutes. The API's own message
+ * stays on the server; only the literal travels.
+ *
+ * No `allowSaveCookies` and no revalidation, because the API mints no session
+ * here - the visitor is still signed out, and the form sends them to the login
+ * page.
+ */
export const mutationApi = async ({
password,
token,
userId,
-}: z.infer) => {
+}: ChangePasswordSubmitValues): Promise => {
const res = await fetcher(usersModule, {
module: "users",
path: "/change-password",
@@ -21,7 +34,8 @@ export const mutationApi = async ({
},
});
- if (res.status !== 201) {
- return { error: "internal_server_error" };
- }
+ if (res.status === 400) return { message: "invalid_token" };
+ if (res.status !== 201) return { message: "internal_server_error" };
+
+ return undefined;
};
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.test.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.test.ts
new file mode 100644
index 000000000..b2e885ba3
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ changePasswordFormOutcome,
+ createChangePasswordFormSchema,
+} from "./schema";
+
+const schema = createChangePasswordFormSchema({
+ fieldRequired: "required",
+ invalidPassword: "too weak",
+});
+
+describe("the change-password schema", () => {
+ it("applies the registration form's password rules", () => {
+ // Imported rather than restated, so this is really a test that the two
+ // screens cannot drift apart on what a strong password is.
+ expect(schema.safeParse({ password: "Test123!" }).success).toBe(true);
+ expect(schema.safeParse({ password: "test" }).success).toBe(false);
+ });
+
+ it("rejects a weak password with the message it was given", () => {
+ const parsed = schema.safeParse({ password: "test1234" });
+
+ expect(parsed.error?.issues[0]?.message).toBe("too weak");
+ });
+
+ it("asks for nothing but the password", () => {
+ // The token and the account id come from the URL, not from a field, which is
+ // why they are not in this schema at all.
+ expect(Object.keys(schema.shape)).toEqual(["password"]);
+ });
+});
+
+describe("what a submit result means for the screen", () => {
+ it("reads success as success, and leaves the navigation to the caller", () => {
+ expect(changePasswordFormOutcome(undefined)).toEqual({ kind: "success" });
+ });
+
+ it("keeps an unusable link apart from a server failure", () => {
+ // The visitor can act on the first (ask for a fresh link) and not on the
+ // second, which is the whole reason the distinction survives this far.
+ expect(changePasswordFormOutcome({ message: "invalid_token" })).toEqual({
+ kind: "toast",
+ reason: "invalid_token",
+ });
+ expect(
+ changePasswordFormOutcome({ message: "internal_server_error" }),
+ ).toEqual({ kind: "toast", reason: "server" });
+ });
+});
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts
new file mode 100644
index 000000000..8f9d087c7
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts
@@ -0,0 +1,77 @@
+import { z } from "zod";
+
+import type { PasswordFieldMessages } from "../../sign-up/form/schema";
+import type { RecoveryLink } from "../recovery-link";
+
+import { createPasswordZodSchema } from "../../sign-up/form/schema";
+
+/**
+ * The "choose a new password" form's shape and its failure vocabulary, with no
+ * React in sight.
+ *
+ * The password rules are *imported* rather than restated. They are the
+ * registration form's rules - one function of two translated strings in
+ * `sign-up/form/schema.ts` - and a second copy here would be a second answer to
+ * "what is a strong enough password", which is precisely the kind of pair that
+ * drifts.
+ */
+
+export type ChangePasswordFormMessages = PasswordFieldMessages;
+
+export const createChangePasswordFormSchema = (
+ messages: ChangePasswordFormMessages,
+) =>
+ z.object({
+ password: createPasswordZodSchema(messages),
+ });
+
+export type ChangePasswordFormSchema = ReturnType<
+ typeof createChangePasswordFormSchema
+>;
+export type ChangePasswordFormValues = z.infer;
+
+/**
+ * What the form sends: the new password, plus the link it is acting on.
+ *
+ * The link travels as a {@link RecoveryLink} - already parsed, `userId` already
+ * a number - rather than as the raw search parameters, so a screen cannot hand
+ * the transport a `userId` of `"abc"` and no layer has to coerce one. See
+ * `../recovery-link.ts`.
+ */
+export type ChangePasswordSubmitValues = RecoveryLink & { password: string };
+
+/**
+ * What the API told us about a password change.
+ *
+ * `undefined` is success. `'invalid_token'` is the API's `400`: the row it looks
+ * up by `userId` + `token` + an unexpired `expiresAt` was not there, which means
+ * the link was wrong, already used, or older than thirty minutes. It is kept
+ * apart from the generic failure because it is the one a visitor can act on -
+ * ask for a fresh link - whereas a `500` is nothing they can do anything about.
+ *
+ * The API's own message (`"Invalid token"`) never travels; only this literal
+ * does.
+ */
+export type ChangePasswordMutationResult =
+ undefined | { message: "internal_server_error" | "invalid_token" };
+
+/**
+ * What a submit result means for the screen.
+ *
+ * - `"success"` - raise the success toast and leave for the login page. The API
+ * does *not* sign the visitor in (`users/routes/change-password.route.ts`
+ * mints no session), so the next step is genuinely to log in.
+ * - `"toast"` - a failure toast, with `reason` deciding which message. The form
+ * stays where it is either way.
+ */
+export const changePasswordFormOutcome = (
+ result: ChangePasswordMutationResult,
+):
+ | { kind: "success" }
+ | { kind: "toast"; reason: "invalid_token" | "server" } =>
+ result?.message
+ ? {
+ kind: "toast",
+ reason: result.message === "invalid_token" ? "invalid_token" : "server",
+ }
+ : { kind: "success" };
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts
new file mode 100644
index 000000000..2c2e5bc70
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts
@@ -0,0 +1,101 @@
+"use client";
+
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { AutoFormOnSubmit } from "@/components/form/auto-form";
+
+import type { RecoveryLink } from "../recovery-link";
+import type {
+ ChangePasswordFormSchema,
+ ChangePasswordMutationResult,
+ ChangePasswordSubmitValues,
+} from "./schema";
+
+import {
+ changePasswordFormOutcome,
+ createChangePasswordFormSchema,
+} from "./schema";
+
+export type { ChangePasswordSubmitValues };
+
+/**
+ * How the form sets a new password.
+ *
+ * The whole of the framework boundary for password recovery's second half. It
+ * takes the new password together with the already-parsed link it is acting on,
+ * and answers what happened. Next.js calls a server action; TanStack Start calls
+ * a server function.
+ */
+export type ChangePasswordSubmit = (
+ values: ChangePasswordSubmitValues,
+) => Promise;
+
+/**
+ * The change-password form's behaviour, with no idea which framework is
+ * rendering it.
+ *
+ * Two props, and both are things this side cannot answer:
+ *
+ * - `onChangePassword` - the mutation.
+ * - `onChanged` - where to go afterwards. The API mints **no session** on a
+ * successful change, so the visitor is still signed out and the only sensible
+ * destination is the login page - but *how* to get there is `useRouter().replace`
+ * in Next.js and a router navigation in TanStack Start, so the caller does it.
+ *
+ * `link` is a {@link RecoveryLink}, which means it has already been through
+ * `parseRecoveryLink`: this hook never sees a raw search parameter and never
+ * coerces one.
+ *
+ * The toasts stay on this side deliberately - they are the same two messages for
+ * the same two reasons in both frameworks. An expired or already-used link gets
+ * the `400` copy rather than the generic internal-error copy, because it is the
+ * one failure a visitor can act on: ask for a fresh link.
+ */
+export const useChangePasswordForm = ({
+ link,
+ onChanged,
+ onChangePassword,
+}: {
+ link: RecoveryLink;
+ onChanged: () => void;
+ onChangePassword: ChangePasswordSubmit;
+}) => {
+ const t = useTranslations("core.auth.change_password");
+ const tSignUp = useTranslations("core.auth.sign_up");
+ const tErrors = useTranslations("core.global.errors");
+
+ const formSchema = createChangePasswordFormSchema({
+ fieldRequired: tErrors("field_required"),
+ invalidPassword: tSignUp("password.invalid"),
+ });
+
+ const onSubmit: AutoFormOnSubmit = async ({
+ password,
+ }) => {
+ const outcome = changePasswordFormOutcome(
+ await onChangePassword({ ...link, password }),
+ );
+
+ if (outcome.kind === "toast") {
+ toast.error(
+ outcome.reason === "invalid_token"
+ ? tErrors("400.title")
+ : tErrors("title"),
+ {
+ description:
+ outcome.reason === "invalid_token"
+ ? tErrors("400.desc")
+ : tErrors("internal_server_error"),
+ },
+ );
+
+ return;
+ }
+
+ toast.success(t("success.title"), { description: t("success.desc") });
+ onChanged();
+ };
+
+ return { formSchema, onSubmit };
+};
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/use-form.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/use-form.ts
deleted file mode 100644
index d6febd7df..000000000
--- a/packages/vitnode/src/views/auth/password-reset/change-password-form/use-form.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useTranslations } from "next-intl";
-import { toast } from "sonner";
-import z from "zod";
-
-import { useRouter } from "@/lib/navigation";
-
-import type { ChangePasswordForm } from "./form";
-
-import { usePasswordZodSchema } from "../../sign-up/form/use-form";
-import { mutationApi } from "./mutation-api.server";
-
-export const useForm = ({
- token,
- userId,
-}: React.ComponentProps) => {
- const t = useTranslations("core.auth.change_password");
- const tError = useTranslations("core.global.errors");
- const passwordSchema = usePasswordZodSchema();
- const { replace } = useRouter();
-
- const formSchema = z.object({
- password: passwordSchema,
- });
-
- const onSubmit = async (data: z.infer) => {
- const mutation = await mutationApi({
- password: data.password,
- token,
- userId: +userId,
- });
-
- if (mutation?.error) {
- toast.error(tError("title"), {
- description: tError("internal_server_error"),
- });
-
- return;
- }
-
- toast.success(t("success.title"), {
- description: t("success.desc"),
- });
- replace("/login");
- };
-
- return {
- formSchema,
- onSubmit,
- };
-};
diff --git a/packages/vitnode/src/views/auth/password-reset/form/form.tsx b/packages/vitnode/src/views/auth/password-reset/form/form.tsx
index d9c23f9c2..87a0a5528 100644
--- a/packages/vitnode/src/views/auth/password-reset/form/form.tsx
+++ b/packages/vitnode/src/views/auth/password-reset/form/form.tsx
@@ -2,96 +2,22 @@
import type z from "zod";
-import { MailCheckIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
-
import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
-import { AutoForm } from "@/components/form/auto-form";
-import { AutoFormInput } from "@/components/form/fields/input";
-import {
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-
-import { useForm } from "./use-form";
-
-function ConfirmationView({ email }: { email: string }) {
- const t = useTranslations("core.auth.reset_password");
- const tSignUp = useTranslations("core.auth.sign_up");
-
- return (
- <>
-
-
-
-
-
- {t("confirmation.title")}
-
-
- {t("confirmation.desc")}
-
-
-
-
-
-
-
-
-
- {t("confirmation.check_spam")}
-
- >
- );
-}
+import { mutationApi } from "./mutation-api.server";
+import { PasswordResetFormContent } from "./password-reset-form-content";
+/**
+ * {@link PasswordResetFormContent}, wired to Next.js.
+ *
+ * One prop wide, and that prop is the whole of the boundary: a server action
+ * that asks the API to send a reset link. Nothing about the screen changes with
+ * the framework, so nothing else is passed.
+ */
export const PasswordResetForm = ({
captcha,
}: {
captcha: z.infer["captcha"];
-}) => {
- const { formSchema, onSubmit, sentEmail } = useForm();
- const t = useTranslations("core.auth.reset_password");
- const tSignUp = useTranslations("core.auth.sign_up");
-
- if (sentEmail) {
- return ;
- }
-
- return (
- <>
-
-
- {t("title")}
-
- {t("desc")}
-
-
-
- (
-
- ),
- },
- ]}
- formSchema={formSchema}
- onSubmit={onSubmit}
- submitButtonProps={{
- className: "w-full",
- children: t("submit"),
- }}
- />
-
- >
- );
-};
+}) => (
+
+);
diff --git a/packages/vitnode/src/views/auth/password-reset/form/mutation-api.server.ts b/packages/vitnode/src/views/auth/password-reset/form/mutation-api.server.ts
index 407e81233..445f5a654 100644
--- a/packages/vitnode/src/views/auth/password-reset/form/mutation-api.server.ts
+++ b/packages/vitnode/src/views/auth/password-reset/form/mutation-api.server.ts
@@ -3,13 +3,27 @@
import { usersModule } from "@/api/modules/users/users.module";
import { fetcher } from "@/lib/fetcher";
+import type {
+ PasswordResetMutationResult,
+ PasswordResetSubmitValues,
+} from "./schema";
+
+/**
+ * Asking the API for a reset link, for Next.js.
+ *
+ * `201` is the only success the route declares, and it is what a *good* request
+ * gets whether or not the address belongs to an account - the API decides that
+ * on its own side and says nothing about it. So there is nothing to inspect
+ * here beyond "did it get through", and nothing this layer could reveal even if
+ * it wanted to.
+ *
+ * No `allowSaveCookies`: this route mints no session, and copying whatever
+ * cookies a reply happened to carry is not something to do by default.
+ */
export const mutationApi = async ({
- email,
captchaToken,
-}: {
- captchaToken: string;
- email: string;
-}) => {
+ email,
+}: PasswordResetSubmitValues): Promise => {
const res = await fetcher(usersModule, {
module: "users",
path: "/reset-password",
@@ -20,7 +34,7 @@ export const mutationApi = async ({
},
});
- if (res.status !== 201) {
- return { error: "internal_server_error" };
- }
+ if (res.status !== 201) return { message: "Internal Server Error" };
+
+ return undefined;
};
diff --git a/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx
new file mode 100644
index 000000000..ff328774e
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import type z from "zod";
+
+import { MailCheckIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
+
+import { AutoForm } from "@/components/form/auto-form";
+import { AutoFormInput } from "@/components/form/fields/input";
+import {
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import {
+ type PasswordResetSubmit,
+ usePasswordResetForm,
+} from "./use-password-reset-form";
+
+export type { PasswordResetSubmit };
+
+/**
+ * "We have sent you a link", and the address it went to.
+ *
+ * Shown for every accepted request, including one for an address with no
+ * account: the API answers `201` either way, so this screen is the only thing a
+ * visitor - or somebody probing for registered addresses - ever sees.
+ */
+const ConfirmationView = ({ email }: { email: string }) => {
+ const t = useTranslations("core.auth.reset_password");
+ const tSignUp = useTranslations("core.auth.sign_up");
+
+ return (
+ <>
+
+
+
+
+
+ {t("confirmation.title")}
+
+
+ {t("confirmation.desc")}
+
+
+
+
+
+
+
+
+
+ {t("confirmation.check_spam")}
+
+ >
+ );
+};
+
+/**
+ * The first half of password recovery - shared.
+ *
+ * One field, one captcha and one callback: {@link PasswordResetSubmit} is the
+ * only framework-specific part, and it is a prop. The form no longer imports a
+ * server action, so a TanStack Start route renders exactly the card the Next.js
+ * page renders.
+ */
+export const PasswordResetFormContent = ({
+ captcha,
+ onRequestReset,
+}: {
+ captcha: z.infer["captcha"];
+ onRequestReset: PasswordResetSubmit;
+}) => {
+ const { formSchema, onSubmit, sentEmail } = usePasswordResetForm({
+ onRequestReset,
+ });
+ const t = useTranslations("core.auth.reset_password");
+ const tSignUp = useTranslations("core.auth.sign_up");
+
+ if (sentEmail) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ {t("title")}
+
+ {t("desc")}
+
+
+
+ (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={onSubmit}
+ submitButtonProps={{
+ className: "w-full",
+ children: t("submit"),
+ }}
+ />
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/views/auth/password-reset/form/schema.test.ts b/packages/vitnode/src/views/auth/password-reset/form/schema.test.ts
new file mode 100644
index 000000000..fa2415ba9
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/form/schema.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createPasswordResetFormSchema,
+ passwordResetFormOutcome,
+} from "./schema";
+
+const schema = createPasswordResetFormSchema({ invalidEmail: "not an email" });
+
+describe("the reset-request schema", () => {
+ it("accepts an email address", () => {
+ expect(schema.parse({ email: "test@test.com" })).toEqual({
+ email: "test@test.com",
+ });
+ });
+
+ it("rejects a value that is not an email address, with the message it was given", () => {
+ const parsed = schema.safeParse({ email: "test" });
+
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.message).toBe("not an email");
+ });
+
+ it("defaults the field, so AutoForm renders a controlled input", () => {
+ expect(schema.shape.email.def.defaultValue).toBe("");
+ });
+});
+
+describe("what a submit result means for the screen", () => {
+ it("shows the confirmation screen for an accepted request", () => {
+ expect(passwordResetFormOutcome(undefined)).toEqual({
+ kind: "confirmation",
+ });
+ });
+
+ it("has no outcome that could mean the address does not exist", () => {
+ // The anti-enumeration property, stated as a test: the API answers the same
+ // 201 either way, so the only two outcomes are "accepted" and "the request
+ // failed". A third would be a leak.
+ expect(passwordResetFormOutcome(undefined).kind).toBe("confirmation");
+ expect(
+ passwordResetFormOutcome({ message: "Internal Server Error" }).kind,
+ ).toBe("toast");
+ });
+});
diff --git a/packages/vitnode/src/views/auth/password-reset/form/schema.ts b/packages/vitnode/src/views/auth/password-reset/form/schema.ts
new file mode 100644
index 000000000..9374424fa
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/form/schema.ts
@@ -0,0 +1,66 @@
+import { z } from "zod";
+
+/**
+ * The "send me a reset link" form's shape and its failure vocabulary, with no
+ * React in sight.
+ *
+ * One field and two outcomes, so this is a small module - but it is the same
+ * split the sign-in and sign-up forms make, and it is what lets the interesting
+ * half be checked without a renderer or a request.
+ */
+
+export interface PasswordResetFormMessages {
+ /** Shown when the email field is not an email address. */
+ invalidEmail: string;
+}
+
+export const createPasswordResetFormSchema = ({
+ invalidEmail,
+}: PasswordResetFormMessages) =>
+ z.object({
+ email: z.email({ message: invalidEmail }).default(""),
+ });
+
+export type PasswordResetFormSchema = ReturnType<
+ typeof createPasswordResetFormSchema
+>;
+export type PasswordResetFormValues = z.infer;
+
+/** What the form sends: the address, and the captcha the route requires. */
+export interface PasswordResetSubmitValues {
+ captchaToken: string;
+ email: string;
+}
+
+/**
+ * What the API told us about a reset request.
+ *
+ * `undefined` means accepted - and *only* that. The API deliberately answers
+ * `201` whether or not the address belongs to an account, and whether or not it
+ * decided to skip the send because one was already requested in the last five
+ * minutes (`users/routes/reset-passowrd.route.ts`). That is the product's
+ * anti-enumeration behaviour, so this type has no shape in which "no such
+ * account" could be expressed: there is nothing to report but "we have taken
+ * your request".
+ *
+ * `{ message: 'Internal Server Error' }` is a request that did not reach that
+ * point at all - the transport failed, the rate limiter refused it, the API
+ * errored - and the screen raises the internal-error toast rather than claiming
+ * an email is on its way.
+ */
+export type PasswordResetMutationResult =
+ undefined | { message: "Internal Server Error" };
+
+/**
+ * What a submit result means for the screen.
+ *
+ * - `"confirmation"` - swap the card for "check your email", printing the
+ * address the visitor typed. Reached for *every* accepted request, which is
+ * exactly why it reveals nothing.
+ * - `"toast"` - the internal-error toast; the form stays as it is so the visitor
+ * can try again.
+ */
+export const passwordResetFormOutcome = (
+ result: PasswordResetMutationResult,
+): { kind: "confirmation" } | { kind: "toast" } =>
+ result?.message ? { kind: "toast" } : { kind: "confirmation" };
diff --git a/packages/vitnode/src/views/auth/password-reset/form/use-form.ts b/packages/vitnode/src/views/auth/password-reset/form/use-form.ts
deleted file mode 100644
index 599886c1e..000000000
--- a/packages/vitnode/src/views/auth/password-reset/form/use-form.ts
+++ /dev/null
@@ -1,41 +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 useForm = () => {
- const t = useTranslations("core.auth.sign_up");
- const tError = useTranslations("core.global.errors");
- const [sentEmail, setSentEmail] = React.useState("");
-
- const formSchema = z.object({
- email: z.email({ message: t("email.invalid") }).default(""),
- });
-
- const onSubmit: AutoFormOnSubmit = async (
- data,
- _form,
- { captchaToken },
- ) => {
- const mutation = await mutationApi({ email: data.email, captchaToken });
- if (mutation?.error) {
- toast.error(tError("title"), {
- description: tError("internal_server_error"),
- });
-
- return;
- }
-
- setSentEmail(data.email);
- };
-
- return {
- formSchema,
- onSubmit,
- sentEmail,
- };
-};
diff --git a/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts b/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts
new file mode 100644
index 000000000..79831e2d4
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts
@@ -0,0 +1,78 @@
+"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 {
+ PasswordResetFormSchema,
+ PasswordResetMutationResult,
+ PasswordResetSubmitValues,
+} from "./schema";
+
+import {
+ createPasswordResetFormSchema,
+ passwordResetFormOutcome,
+} from "./schema";
+
+export type { PasswordResetSubmitValues };
+
+/**
+ * How the form asks for a reset link.
+ *
+ * The whole of the framework boundary for password recovery's first half: an
+ * address and a captcha token in, "it was accepted" or "it failed" out. Next.js
+ * calls a server action; TanStack Start calls a server function. Neither is
+ * imported here.
+ */
+export type PasswordResetSubmit = (
+ values: PasswordResetSubmitValues,
+) => Promise;
+
+/**
+ * The reset-request form's behaviour, with no idea which framework is rendering
+ * it.
+ *
+ * `sentEmail` is the whole of its state, and it is local on purpose: the
+ * confirmation screen prints the address the visitor typed, which this side
+ * already has, so nothing needs to come back from the server for it. Which is
+ * also what makes the screen say the same thing for an address that exists and
+ * one that does not.
+ */
+export const usePasswordResetForm = ({
+ onRequestReset,
+}: {
+ onRequestReset: PasswordResetSubmit;
+}) => {
+ const t = useTranslations("core.auth.sign_up");
+ const tErrors = useTranslations("core.global.errors");
+ const [sentEmail, setSentEmail] = React.useState("");
+
+ const formSchema = createPasswordResetFormSchema({
+ invalidEmail: t("email.invalid"),
+ });
+
+ const onSubmit: AutoFormOnSubmit = async (
+ { email },
+ _form,
+ { captchaToken },
+ ) => {
+ const outcome = passwordResetFormOutcome(
+ await onRequestReset({ captchaToken, email }),
+ );
+
+ if (outcome.kind === "toast") {
+ toast.error(tErrors("title"), {
+ description: tErrors("internal_server_error"),
+ });
+
+ return;
+ }
+
+ setSentEmail(email);
+ };
+
+ return { formSchema, onSubmit, sentEmail };
+};
diff --git a/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx b/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx
new file mode 100644
index 000000000..0fdb45d47
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx
@@ -0,0 +1,39 @@
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * The card both recovery screens live in - shared.
+ *
+ * Thin on purpose: the two forms render their own `CardHeader` and
+ * `CardContent`, so all this owns is the page's measure and the card around it.
+ * It exists so that "the reset-password page" is one layout rather than two that
+ * have to be kept looking alike, in the same way `SignInContent` is.
+ *
+ * Not a client component. It has no hooks and no strings, which lets the Next.js
+ * page keep rendering it on the server with its `` boundary inside.
+ */
+export const PasswordResetContent = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => (
+
+ {children}
+
+);
+
+/** Either form's shape while the deployment configuration is still in flight. */
+export const PasswordResetSkeleton = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+);
diff --git a/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx b/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx
index 116b01fac..0aece5509 100644
--- a/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx
+++ b/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx
@@ -6,30 +6,42 @@ import React from "react";
import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
import { I18nProvider } from "@/components/i18n-provider";
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
import { getMiddlewareApi } from "@/lib/api/get-middleware-api";
import { ChangePasswordForm } from "./change-password-form/form";
import { PasswordResetForm } from "./form/form";
+import {
+ PasswordResetContent,
+ PasswordResetSkeleton,
+} from "./password-reset-content";
+import { parseRecoveryLink } from "./recovery-link";
type Captcha = z.infer["captcha"];
-const PasswordResetContent = async ({
+/**
+ * Which of the two recovery screens this URL asks for.
+ *
+ * `parseRecoveryLink` rather than `if (token && userId)`: the query comes out of
+ * an email and anyone can craft one, so a `?token=%20&userId=0` must render the
+ * request form rather than a change-password form that can only fail. The rule
+ * is shared with the TanStack Start route, which reads the same parameters
+ * through its own search schema.
+ */
+const PasswordResetRouteContent = async ({
captcha,
searchParams,
}: {
captcha: Captcha;
- searchParams: Promise<{ token: string; userId: string }>;
+ searchParams: Promise<{ token?: string; userId?: string }>;
}) => {
- const { token, userId } = await searchParams;
+ const link = parseRecoveryLink(await searchParams);
- if (token && userId) {
+ if (link) {
return (
-
+
);
}
@@ -43,36 +55,22 @@ const PasswordResetContent = async ({
);
};
-const PasswordResetContentSkeleton = () => (
- <>
-
-
-
-
-
-
-
-
-
-
- >
-);
-
export const PasswordResetView = async ({
searchParams,
}: {
- searchParams: Promise<{ token: string; userId: string }>;
+ searchParams: Promise<{ token?: string; userId?: string }>;
}) => {
- const { isEmail, captcha } = await getMiddlewareApi();
+ const { captcha, isEmail } = await getMiddlewareApi();
if (!isEmail) notFound();
return (
-
-
- }>
-
-
-
-
+
+ }>
+
+
+
);
};
diff --git a/packages/vitnode/src/views/auth/password-reset/recovery-link.test.ts b/packages/vitnode/src/views/auth/password-reset/recovery-link.test.ts
new file mode 100644
index 000000000..7fde8f60b
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/recovery-link.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+
+import { parseRecoveryLink } from "./recovery-link";
+
+/** What the API actually puts in the email: 32 random bytes as base64url. */
+const TOKEN = "PSyRy0nQ0hRnfx3iCYldQ40mBLU9lqfDWtvNhrTsJI4";
+
+describe("parsing a recovery link", () => {
+ it("accepts what the reset email builds", () => {
+ expect(parseRecoveryLink({ token: TOKEN, userId: "123" })).toEqual({
+ token: TOKEN,
+ userId: 123,
+ });
+ });
+
+ it("accepts a userId that is already a number", () => {
+ // A TanStack Start route's `validateSearch` may well have coerced it before
+ // this sees it; the Next.js view hands over the raw string.
+ expect(parseRecoveryLink({ token: TOKEN, userId: 123 })).toEqual({
+ token: TOKEN,
+ userId: 123,
+ });
+ });
+
+ it.each([
+ ["nothing at all", {}],
+ ["a token with no account", { token: TOKEN }],
+ ["an account with no token", { userId: "123" }],
+ ])("answers null for %s, so the request form is shown", (_case, input) => {
+ expect(parseRecoveryLink(input)).toBeNull();
+ });
+
+ it.each([
+ ["an empty userId", ""],
+ ["a zero userId", "0"],
+ ["a negative userId", "-1"],
+ ["a fractional userId", "1.5"],
+ ["a signed userId", "+1"],
+ ["a padded userId", " 1"],
+ ["an exponent", "1e3"],
+ ["hexadecimal", "0x10"],
+ ["a word", "abc"],
+ ["a boolean", true],
+ ["a list", ["1", "2"]],
+ ["null", null],
+ ["past the safe integer range", "9007199254740993"],
+ ])("rejects %s rather than coercing it", (_case, userId) => {
+ // `Number("")` is 0 and `Number(true)` is 1, which is exactly why the digits
+ // are checked before the coercion rather than after.
+ expect(parseRecoveryLink({ token: TOKEN, userId })).toBeNull();
+ });
+
+ it.each([
+ ["an empty token", ""],
+ ["a whitespace token", " "],
+ ["a token that is too short to be one", "abc"],
+ ["a path traversal attempt", `../../${TOKEN}`],
+ ["a token carrying a newline", `${TOKEN}\n`],
+ ["a token carrying a space", `${TOKEN} x`],
+ ["a token with a percent escape", `${TOKEN}%2F`],
+ ["an unbounded token", "a".repeat(513)],
+ ["a non-string token", 123],
+ ])("rejects %s", (_case, token) => {
+ expect(parseRecoveryLink({ token, userId: "123" })).toBeNull();
+ });
+
+ it("keeps the token exactly as it arrived", () => {
+ // The API compares it byte for byte against the stored row, so any
+ // normalisation here would break every real link.
+ const link = parseRecoveryLink({ token: TOKEN, userId: "1" });
+
+ expect(link?.token).toBe(TOKEN);
+ });
+});
diff --git a/packages/vitnode/src/views/auth/password-reset/recovery-link.ts b/packages/vitnode/src/views/auth/password-reset/recovery-link.ts
new file mode 100644
index 000000000..9e653cfe9
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/recovery-link.ts
@@ -0,0 +1,83 @@
+import { z } from "zod";
+
+/**
+ * The two values a password-recovery email puts in the URL, judged before
+ * anything is done with them.
+ *
+ * `/login/reset-password?token=...&userId=...` is a link in an email, which means
+ * the query is the least trustworthy input on the recovery screens: anyone can
+ * craft one, and the page decides *which form to render* from whether both
+ * values are present. So the rule is a schema rather than a truthiness check,
+ * and it lives here - pure, framework-free, with no React and no fetcher - so
+ * both the Next.js view and a TanStack Start route reach the same verdict from
+ * the same code.
+ *
+ * ## What it is not
+ *
+ * Not authentication. The API is the boundary and stays the boundary: it looks
+ * the row up by `userId` *and* `token` *and* an unexpired `expiresAt`, and
+ * answers `400 Invalid token` when any of the three does not match
+ * (`users/routes/change-password.route.ts`). Nothing here can grant a password
+ * change; it only decides whether a request is worth making at all, and stops a
+ * crafted URL from turning into a request carrying an unbounded string or a
+ * `userId` the API would have to coerce.
+ */
+
+/**
+ * The recovery token, as it may appear in a URL.
+ *
+ * The API generates it as `randomBytes(32).toString("base64url")` - 43
+ * characters of `[A-Za-z0-9_-]` - so the character class is a true statement
+ * about the value rather than a guess, and it is what excludes whitespace,
+ * control characters and path separators. The length bounds are deliberately
+ * loose around the real 43 so a change to the API's token generation widens
+ * rather than breaks this.
+ */
+const recoveryTokenSchema = z
+ .string()
+ .min(16)
+ .max(512)
+ .regex(/^[A-Za-z0-9_-]+$/);
+
+/**
+ * The account the link belongs to.
+ *
+ * A query parameter arrives as a string, and `Number("")` is `0` while
+ * `Number(true)` is `1` - so the digits are checked *before* the coercion rather
+ * than after, and only a string of digits or an actual number is accepted. The
+ * cap is `Number.MAX_SAFE_INTEGER` because past it two different ids compare
+ * equal, which is not a value to send to a lookup.
+ */
+const recoveryUserIdSchema = z
+ .union([z.number(), z.string().regex(/^\d+$/)])
+ .transform(value => Number(value))
+ .pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER));
+
+export const recoveryLinkSchema = z.object({
+ token: recoveryTokenSchema,
+ userId: recoveryUserIdSchema,
+});
+
+/** A recovery link this app is willing to act on. */
+export type RecoveryLink = z.infer;
+
+/**
+ * The link's two values, normalised, or `null`.
+ *
+ * `null` is the answer for every unusable shape - missing, empty, malformed, out
+ * of range - because the screens have exactly one thing to do about all of them:
+ * render the "request a reset link" form instead of the "choose a new password"
+ * one. Which is what the Next.js view already does with `if (token && userId)`,
+ * only spelled as a rule that a crafted `?token=%20&userId=0` cannot walk past.
+ */
+export const parseRecoveryLink = (input: {
+ token?: unknown;
+ userId?: unknown;
+}): null | RecoveryLink => {
+ const parsed = recoveryLinkSchema.safeParse({
+ token: input.token,
+ userId: input.userId,
+ });
+
+ return parsed.success ? parsed.data : null;
+};
diff --git a/packages/vitnode/src/views/auth/settings/devices/device-item.tsx b/packages/vitnode/src/views/auth/settings/devices/device-item.tsx
index 16f97bcba..c9afe64a4 100644
--- a/packages/vitnode/src/views/auth/settings/devices/device-item.tsx
+++ b/packages/vitnode/src/views/auth/settings/devices/device-item.tsx
@@ -1,15 +1,17 @@
-import { MonitorIcon, SmartphoneIcon, TabletIcon } from "lucide-react";
-import { getTranslations } from "next-intl/server";
+"use client";
-import type { DevicesApi } from "@/lib/api/get-devices-api";
+import { MonitorIcon, SmartphoneIcon, TabletIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
import { DateFormat } from "@/components/date-format";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
-import { RevokeDeviceButton } from "./revoke-device-button";
+import type { Device } from "./devices-query";
+import type { RevokeDevice } from "./devices-revoke";
-type Device = DevicesApi["devices"][number];
+import { isRevokableDevice } from "./devices-revoke";
+import { RevokeDeviceButton } from "./revoke-device-button";
const icons = {
desktop: MonitorIcon,
@@ -17,25 +19,36 @@ const icons = {
tablet: TabletIcon,
} as const;
-export const DeviceItem = async ({
- browser,
- deviceType,
- expiresAt,
- ipAddress,
- isCurrent,
- lastSeen,
- os,
- publicId,
-}: Device) => {
- const t = await getTranslations("core.auth.settings.devices");
- const Icon = icons[deviceType];
+/**
+ * One device, as a card both frameworks render.
+ *
+ * Everything that used to make this a Next.js Server Component has been taken
+ * out: it no longer awaits `getTranslations`, and the revoke it offers arrives as
+ * a prop instead of being imported. What is left is the part that was always
+ * worth sharing - the icon, the current-device badge, the relative last-seen
+ * date, the three details and the layout of all of it.
+ *
+ * The row is handed over whole rather than spread as eight props, which is what
+ * lets `isRevokableDevice` read it: the rule about the current device is one
+ * statement in `devices-revoke.ts` and this is the only place it is applied to a
+ * button.
+ */
+export const DeviceItem = ({
+ device,
+ onRevoke,
+}: {
+ device: Device;
+ onRevoke: RevokeDevice;
+}) => {
+ const t = useTranslations("core.auth.settings.devices");
+ const Icon = icons[device.deviceType];
const details = [
- { label: t("browser"), value: browser },
- { label: t("ip_address"), value: ipAddress },
+ { label: t("browser"), value: device.browser },
+ { label: t("ip_address"), value: device.ipAddress },
{
label: t("session_expires"),
- value: ,
+ value: ,
},
];
@@ -48,15 +61,27 @@ export const DeviceItem = async ({
- {os}
- {isCurrent && {t("current_device")} }
+ {device.os}
+ {device.isCurrent && {t("current_device")} }
- {t("last_active")}:
+ {t("last_active")}:
- {!isCurrent && }
+ {/*
+ No button on the current device, because the API refuses to revoke it -
+ `DELETE /users/devices/{publicId}` answers 400 for the id matching the
+ requester's own device cookie. Offering it would put a refusal behind a
+ button whose only outcome is an error toast.
+ */}
+ {isRevokableDevice(device) && (
+
+ )}
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts b/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts
new file mode 100644
index 000000000..20b95f79b
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts
@@ -0,0 +1,257 @@
+// @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, "../../../..");
+
+/**
+ * `/settings/devices`, split down the middle.
+ *
+ * The same boundary `files-boundaries.test.ts` and `auth-boundaries.test.ts`
+ * draw, with the same machinery and for the same reason: a shared module that
+ * reaches `next/headers`, a server action or `@/lib/navigation` cannot be loaded
+ * by a TanStack Start route, and nothing about that failure is visible until
+ * somebody tries. A scan is the only way to state it, because the offending
+ * import is usually two files away from the one being written - this feature's
+ * would have been the server action, imported by the revoke button, behind the
+ * list.
+ */
+const SHARED = {
+ item: join(here, "device-item.tsx"),
+ list: join(here, "devices-content.tsx"),
+ query: join(here, "devices-query.ts"),
+ revoke: join(here, "devices-revoke.ts"),
+ revokeButton: join(here, "revoke-device-button.tsx"),
+ skeleton: join(here, "devices-list-skeleton.tsx"),
+};
+
+/** The Next.js half: `next/navigation`, `next/cache`, `fetcher()`, the action. */
+const NEXT_WRAPPERS = {
+ list: join(here, "devices-list.tsx"),
+ page: join(here, "devices.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: the query module imports the users
+ * API module's *type* to keep the fetcher's route literals inferring, and that
+ * module is a Hono server module. It is erased at compile time and never reaches
+ * a bundle, so counting it would fail this suite on something that cannot break.
+ */
+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 -
+ * `ConfirmActionAlertDialog`, which is what the revoke button's dialog is. 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,
+}));
+
+const wrapperEntries = Object.entries(NEXT_WRAPPERS).map(([name, path]) => ({
+ name,
+ path,
+}));
+
+describe("the import scan finds what it is looking for", () => {
+ // Most assertions below are "found nothing" ones, which a scanner that
+ // silently matches nothing also satisfies. The Next wrappers are the control:
+ // they provably import the things the shared modules must not.
+ it.each(wrapperEntries)(
+ "finds the Next-only imports in the $name wrapper",
+ ({ path }) => {
+ expect(offenders(path, NEXT_ONLY)).not.toEqual([]);
+ },
+ );
+
+ it("walks past the entry file into its dependencies", () => {
+ // `next/headers` is two hops from the list wrapper - through `@/lib/fetcher` -
+ // not one.
+ expect(offenders(NEXT_WRAPPERS.list, ["next/headers"]).join()).toContain(
+ "lib/fetcher.ts",
+ );
+ });
+});
+
+describe("the shared devices modules 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 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.
+ // The revoke 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,
+ );
+ });
+
+ it("never imports the API's own module for one plugin id", () => {
+ // The fetchers need the users module's *type* to keep route literals
+ // inferring; a value import would drag Hono, Drizzle and `@/database` into
+ // the browser bundle of every page that lists a device.
+ const reached = [...externalGraph(SHARED.query).keys()];
+
+ expect(reached).not.toContain("drizzle-orm");
+ expect(reached.some(one => one.startsWith("hono"))).toBe(false);
+ });
+});
+
+describe("the shared list takes its framework parts as props", () => {
+ const withoutComments = (path: string): string =>
+ readFileSync(path, "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/\/\/.*$/gm, "");
+
+ it("is handed the devices rather than fetching them", () => {
+ const code = withoutComments(SHARED.list);
+
+ expect(code).toContain("devices: Device[];");
+ expect(code).not.toContain("useQuery");
+ expect(code).not.toContain("fetcher");
+ });
+
+ it("is handed the revoke rather than importing one", () => {
+ const code = withoutComments(SHARED.list);
+
+ expect(code).toContain("onRevoke: RevokeDevice;");
+ });
+
+ it("passes the revoke down to the button rather than the button finding it", () => {
+ expect(withoutComments(SHARED.revokeButton)).toContain(
+ "onRevoke: RevokeDevice;",
+ );
+ });
+});
+
+describe("the Next wrapper keeps the Next-only pieces", () => {
+ it("is the only half that fetches and refuses", () => {
+ const code = readFileSync(NEXT_WRAPPERS.list, "utf8");
+
+ expect(code).toContain("notFound");
+ expect(runtimeImports(NEXT_WRAPPERS.list)).toContain("@/lib/fetcher");
+ });
+
+ it("builds its request from the shared contract rather than its own", () => {
+ // The point of the split: a list means the same thing in both apps because
+ // both call the same function, not because two places look alike.
+ expect(readFileSync(NEXT_WRAPPERS.list, "utf8")).toContain(
+ "devicesRequest",
+ );
+ });
+
+ it("is where the server action and its revalidate live", () => {
+ const action = readFileSync(join(here, "revoke-action.server.ts"), "utf8");
+
+ expect(action).toContain('"use server"');
+ expect(action).toContain("revalidatePath");
+ // ...and it applies the shared refresh rule rather than a second copy of it.
+ expect(action).toContain("shouldRefreshAfterRevoke");
+ expect(action).toContain("revokeDeviceRequest");
+ });
+});
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx b/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx
new file mode 100644
index 000000000..4b7517458
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useTranslations } from "use-intl";
+
+import type { Device } from "./devices-query";
+import type { RevokeDevice } from "./devices-revoke";
+
+import { DeviceItem } from "./device-item";
+
+/**
+ * The visitor's devices, as a list both frameworks render.
+ *
+ * The presentation half of `/settings/devices`, and the whole of it: the cards,
+ * the spacing between them, and the sentence that stands in for an empty list.
+ *
+ * Next.js devices-list.tsx fetch + notFound + server action
+ * TanStack Start routes/.../settings/devices loader + useSuspenseQuery + browser revoke
+ * \ /
+ * DevicesContent
+ *
+ * ## What it does not own
+ *
+ * **Fetching.** It is handed a list. Which list, and how it was fetched, is
+ * `devices-query.ts`'s - the same definition a TanStack loader warms and a
+ * Next.js Server Component awaits. That is also why an API failure never reaches
+ * here: it is a rejected query, not an empty array, so this component's "no
+ * devices" state means only that the API said so.
+ *
+ * **Revoking.** One callback, because the two frameworks genuinely differ: one
+ * ends in `revalidatePath`, the other in a query invalidation, and neither can
+ * exist in the other's runtime. The request, the status mapping and the rule
+ * about the current device are shared - see `devices-revoke.ts`.
+ *
+ * **The heading.** Deliberately outside, in each framework's own page. The
+ * Next.js page renders `HeaderContent` above a `` whose fallback is
+ * `DevicesListSkeleton`, so the title is on screen while the list is still
+ * streaming; folding the heading in here would put it behind the same boundary
+ * and lose that.
+ */
+export const DevicesContent = ({
+ devices,
+ onRevoke,
+}: {
+ devices: Device[];
+ onRevoke: RevokeDevice;
+}) => {
+ const t = useTranslations("core.auth.settings.devices");
+
+ if (devices.length === 0) {
+ return {t("empty")}
;
+ }
+
+ return (
+
+ {devices.map(device => (
+
+ ))}
+
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-list.tsx b/packages/vitnode/src/views/auth/settings/devices/devices-list.tsx
index 3a1e7c825..22362703c 100644
--- a/packages/vitnode/src/views/auth/settings/devices/devices-list.tsx
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-list.tsx
@@ -1,24 +1,46 @@
-import { getTranslations } from "next-intl/server";
+import { notFound } from "next/navigation";
-import { getDevicesApi } from "@/lib/api/get-devices-api";
+import { usersModule } from "@/api/modules/users/users.module";
+import { fetcher } from "@/lib/fetcher";
-import { DeviceItem } from "./device-item";
+import { DevicesContent } from "./devices-content";
+import { devicesRequest } from "./devices-query";
+import { revokeDeviceAction } from "./revoke-action.server";
+/**
+ * The Next.js half of `/settings/devices`: read the list, then hand it to the
+ * shared one.
+ *
+ * Everything Next.js about the feature is in this file. It is a Server
+ * Component, so it fetches with `fetcher()` - which reads the visitor's session
+ * and device cookies through `next/headers`, and the device cookie is what makes
+ * `isCurrent` correct - and answers a refusal with `notFound()`, which only
+ * exists here. The revoke callback is the server action, which ends in
+ * `revalidatePath`: the one step that cannot be shared.
+ *
+ * The request itself is *not* Next.js's. `devicesRequest()` is the same function
+ * the TanStack Start transport calls, so both applications ask the API for the
+ * same thing rather than in two places that merely look alike.
+ *
+ * ## A refused read is not an empty list
+ *
+ * This used to be `getDevicesApi()`, which called `res.json()` on whatever came
+ * back and handed the result straight to the list. A `401`, `403` or `429` body
+ * parses perfectly happily and has no `devices` in it, so the page either
+ * rendered "No active devices." or crashed reading `.length` of `undefined` -
+ * and the first of those is the most alarming thing this page can say, said about
+ * an outage. `notFound()` is the same answer `/files` gives to the same problem:
+ * a finite, honest "this page is not available", instead of a confident lie about
+ * the visitor's sessions.
+ */
export const DevicesList = async () => {
- const [t, { devices }] = await Promise.all([
- getTranslations("core.auth.settings.devices"),
- getDevicesApi(),
- ]);
+ const res = await fetcher(usersModule, devicesRequest());
- if (devices.length === 0) {
- return {t("empty")}
;
+ if (res.status !== 200) {
+ return notFound();
}
- return (
-
- {devices.map(device => (
-
- ))}
-
- );
+ const { devices } = await res.json();
+
+ return ;
};
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts
new file mode 100644
index 000000000..25baf1826
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts
@@ -0,0 +1,266 @@
+import { hashKey } from "@tanstack/react-query";
+import { describe, expect, it } from "vitest";
+
+import {
+ DEVICE_TYPES,
+ devicesQueryKey,
+ devicesQueryOptions,
+ devicesRequest,
+ DevicesRequestError,
+ isDevicesRequestError,
+} from "./devices-query";
+import {
+ isDevicePublicId,
+ isRevokableDevice,
+ REVOKE_CURRENT_DEVICE_STATUS,
+ revokeDeviceRequest,
+ revokeResultFromStatus,
+ shouldRefreshAfterRevoke,
+} from "./devices-revoke";
+
+/**
+ * The pure half of the devices contract.
+ *
+ * Everything below is a function over plain values: a request is built, a
+ * response status becomes either a list or an error, a revoke's status becomes a
+ * result, and a result becomes a yes-or-no about refreshing. Nothing here opens a
+ * socket or renders a component - the API has its own suite, and how the cards
+ * look is Playwright's.
+ */
+
+describe("the request the API is asked for", () => {
+ it("names the list route on the users module, with no parameters", () => {
+ // No parameters is the point: the route takes none and derives whose devices
+ // these are from the session cookie. A query string here would be a second
+ // source of truth for something the cookie already decides.
+ expect(devicesRequest()).toEqual({
+ method: "get",
+ module: "users",
+ path: "/devices",
+ });
+ });
+
+ it("addresses one device by its public id for a revoke", () => {
+ expect(revokeDeviceRequest({ publicId: "a1b2c3" })).toEqual({
+ args: { params: { publicId: "a1b2c3" } },
+ method: "delete",
+ module: "users",
+ path: "/devices/{publicId}",
+ });
+ });
+});
+
+describe("one list per visitor, one cache entry each", () => {
+ it("is keyed by the owner, under the devices domain", () => {
+ expect(devicesQueryKey(10)).toEqual(["devices", "user", 10]);
+ });
+
+ it("is the same entry however many times it is asked for", () => {
+ // The loader and the component both call the factory, and they have to land
+ // in the same entry or the loader fills one while the component reads the
+ // other.
+ expect(hashKey(devicesQueryOptions({ userId: 10 }).queryKey)).toBe(
+ hashKey(
+ devicesQueryOptions({
+ fetchDevices: async () => Promise.resolve({ devices: [] }),
+ userId: 10,
+ }).queryKey,
+ ),
+ );
+ });
+
+ /**
+ * The privacy invariant, as the key contract rather than as a browser test.
+ *
+ * The browser's `QueryClient` outlives a sign-out, so one document can hold
+ * two visitors. Under the `["devices", "me"]` this replaces, B's loader asked
+ * for the entry A had already filled - and with `refetchOnMount` off, nothing
+ * refetched it, so no request was made and Hono never saw the read it would
+ * have refused.
+ */
+ it("gives two visitors two entries, so one can never read the other's", () => {
+ expect(devicesQueryKey(10)).not.toEqual(devicesQueryKey(20));
+ expect(hashKey(devicesQueryKey(10))).not.toBe(hashKey(devicesQueryKey(20)));
+ });
+
+ it("is what a revoke invalidates, so one visitor's refresh is their own", () => {
+ // Query matches by prefix, and this key has no sub-keys - so it is both the
+ // entry and the family, and invalidating it cannot reach visitor 20.
+ expect(devicesQueryOptions({ userId: 10 }).queryKey).toEqual(
+ devicesQueryKey(10),
+ );
+ });
+
+ it("does not share a prefix with the session entry", () => {
+ // Query matches keys by prefix, so a revoke invalidating this key must not
+ // reach `['vitnode', 'session']` - the one entry a route guard reads.
+ expect(devicesQueryKey(10)[0]).not.toBe("vitnode");
+ });
+
+ it("asks once, because every failure it can have is worse when repeated", () => {
+ expect(devicesQueryOptions({ userId: 10 }).retry).toBe(false);
+ });
+});
+
+/**
+ * The other half of the same rule: the id is a cache address, not a claim.
+ *
+ * If it ever reached the wire it would stop being a cache key and become an
+ * access-control parameter supplied by the browser - so the request is asserted
+ * to be exactly what it was before the key gained an owner.
+ */
+describe("the owner never leaves the browser", () => {
+ it("sends no arguments at all on the list request", () => {
+ // Not "no user id" - no arguments whatsoever. There is nowhere for one to
+ // travel, which is a stronger statement than any absence check.
+ expect(devicesRequest()).not.toHaveProperty("args");
+ expect(Object.keys(devicesRequest()).sort()).toEqual([
+ "method",
+ "module",
+ "path",
+ ]);
+ });
+
+ it("sends the device's public id on a revoke and nothing else", () => {
+ const request = revokeDeviceRequest({ publicId: "a1b2c3" });
+
+ expect(request.args).toEqual({ params: { publicId: "a1b2c3" } });
+ expect(Object.keys(request.args.params)).toEqual(["publicId"]);
+ });
+});
+
+describe("a refused read is not an empty list", () => {
+ it.each([401, 403, 429, 500])(
+ "turns %i into an error rather than a list nobody is signed in on",
+ status => {
+ const error = new DevicesRequestError(status);
+
+ expect(error.status).toBe(status);
+ expect(isDevicesRequestError(error)).toBe(true);
+ // The bug this replaces: `getDevicesApi()` parsed the refusal body, which
+ // has no `devices` in it, and the page said "No active devices."
+ expect(error).not.toHaveProperty("devices");
+ },
+ );
+
+ it("says which status refused, in the message", () => {
+ expect(new DevicesRequestError(429).message).toContain("429");
+ });
+
+ it("is recognised across two copies of the class", () => {
+ // `@vitnode/core` is imported from `dist` by the apps and from `src` by these
+ // tests, so `instanceof` can answer `false` for a genuine one. The guard is
+ // `name`-based, and this is the shape that proves it.
+ const fromAnotherCopy = new Error("The devices API answered 401 ...");
+ fromAnotherCopy.name = "DevicesRequestError";
+
+ expect(isDevicesRequestError(fromAnotherCopy)).toBe(true);
+ });
+
+ it("is not fooled by an ordinary error", () => {
+ expect(isDevicesRequestError(new Error("nope"))).toBe(false);
+ expect(isDevicesRequestError({ status: 401 })).toBe(false);
+ expect(isDevicesRequestError(undefined)).toBe(false);
+ });
+});
+
+describe("the row shape the API promises", () => {
+ it("has exactly the three device types the icons cover", () => {
+ expect([...DEVICE_TYPES]).toEqual(["desktop", "tablet", "mobile"]);
+ });
+});
+
+describe("the current device is the one that cannot be signed out", () => {
+ it("offers no revoke for the session doing the asking", () => {
+ // The API answers 400 for it, so a button here would only ever produce an
+ // error toast.
+ expect(isRevokableDevice({ isCurrent: true })).toBe(false);
+ });
+
+ it("offers a revoke for every other device", () => {
+ expect(isRevokableDevice({ isCurrent: false })).toBe(true);
+ });
+
+ it("names the status the API refuses with", () => {
+ expect(REVOKE_CURRENT_DEVICE_STATUS).toBe(400);
+ });
+});
+
+describe("the public ids a revoke will send", () => {
+ it("accepts the 32 hex characters `DeviceModel` mints", () => {
+ expect(isDevicePublicId("0123456789abcdef0123456789abcdef")).toBe(true);
+ });
+
+ it("accepts a shorter url-safe token, for ids minted by an older scheme", () => {
+ expect(isDevicePublicId("a1b2c3")).toBe(true);
+ expect(isDevicePublicId("a_b-c")).toBe(true);
+ });
+
+ it("refuses an empty id, which would address the list route", () => {
+ // `/devices/` + `""` is `DELETE /devices`, which is a different route.
+ expect(isDevicePublicId("")).toBe(false);
+ });
+
+ it("refuses anything that would leave the path segment", () => {
+ expect(isDevicePublicId("../session")).toBe(false);
+ expect(isDevicePublicId("a/b")).toBe(false);
+ expect(isDevicePublicId("a.b")).toBe(false);
+ expect(isDevicePublicId("%2e%2e")).toBe(false);
+ expect(isDevicePublicId("a b")).toBe(false);
+ });
+
+ it("refuses an id longer than any real one", () => {
+ expect(isDevicePublicId("a".repeat(128))).toBe(true);
+ expect(isDevicePublicId("a".repeat(129))).toBe(false);
+ });
+});
+
+describe("what a revoke's status becomes", () => {
+ it("is done only for the 200 the route declares", () => {
+ expect(revokeResultFromStatus(200)).toEqual({ data: true });
+ });
+
+ it.each([400, 401, 403, 404, 429, 500])(
+ "carries %i back for the dialog to phrase",
+ status => {
+ expect(revokeResultFromStatus(status)).toEqual({ error: { status } });
+ },
+ );
+
+ it("never reports both an outcome and a refusal", () => {
+ expect(revokeResultFromStatus(200).error).toBeUndefined();
+ expect(revokeResultFromStatus(404).data).toBeUndefined();
+ });
+});
+
+describe("whether a finished revoke makes the list stale", () => {
+ it("refreshes when the device actually went", () => {
+ expect(shouldRefreshAfterRevoke({ data: true })).toBe(true);
+ });
+
+ it("refreshes when the row was already wrong", () => {
+ // 404: somebody revoked it first. 400: the list believed it was revokable and
+ // the API considers it current. Either way the screen disagrees with the
+ // server, and refetching is the repair.
+ expect(shouldRefreshAfterRevoke({ error: { status: 404 } })).toBe(true);
+ expect(
+ shouldRefreshAfterRevoke({
+ error: { status: REVOKE_CURRENT_DEVICE_STATUS },
+ }),
+ ).toBe(true);
+ });
+
+ it.each([401, 403, 429, 500, 503])(
+ "leaves the list alone after a %i, which deleted nothing",
+ status => {
+ // A 429 answered by immediately re-reading is the thing the limiter is
+ // asking the app to stop doing; a 401 answered by re-reading blanks the
+ // list the person is looking at.
+ expect(shouldRefreshAfterRevoke({ error: { status } })).toBe(false);
+ },
+ );
+
+ it("does not refresh on a result that says nothing", () => {
+ expect(shouldRefreshAfterRevoke({})).toBe(false);
+ });
+});
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts
new file mode 100644
index 000000000..79433cc87
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts
@@ -0,0 +1,282 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import type { usersModule } from "@/api/modules/users/users.module";
+
+import { CONFIG_PLUGIN } from "@/config";
+import { clientModule, fetcherClient } from "@/lib/fetcher-client";
+
+/**
+ * The devices the signed-in visitor is logged in on, as one query definition.
+ *
+ * Everything about *what* that list is lives here and nowhere else: the request,
+ * the shape that comes back, what counts as a refusal, and the cache entry the
+ * whole thing lands in. A view renders whatever this produces and owns none of
+ * it.
+ *
+ * The split is the one `my-files-query.ts` already paid for. When a component
+ * built one request and a loader built another, the two agreed on the cache key
+ * and on nothing else - so the server-rendered page came from one contract and
+ * every navigation after hydration came from a second one with different
+ * defaults and no status checking. Sharing a key is not sharing a contract.
+ *
+ * The one thing deliberately *not* fixed here is the transport: a loader running
+ * on a server and a component running in a browser cannot reach the API the same
+ * way. So {@link devicesQueryOptions} takes a `fetchDevices` and defaults it to
+ * the browser's, which is the only one a shared module can assume.
+ *
+ * ## Hono is still the boundary
+ *
+ * Nothing below authorizes anything. `GET /api/@vitnode/core/users/devices`
+ * derives the user from the session cookie, scopes the query to their sessions,
+ * and marks the row matching the device cookie as `isCurrent` - so a request
+ * this module builds for a visitor who has just been signed out comes back `401`,
+ * and {@link DevicesRequestError} is what makes that a failed query rather than
+ * an empty list.
+ */
+
+/**
+ * The users module as a value the fetchers can carry without pulling the API
+ * into either bundle. The module is imported as a *type* only, so route
+ * literals, methods and response schemas all still infer; `clientModule`
+ * supplies the one field the fetcher reads at runtime.
+ */
+export const usersModuleRef = clientModule(
+ CONFIG_PLUGIN.pluginId,
+);
+
+/** Which icon a row gets, and the only three values the API will send. */
+export const DEVICE_TYPES = ["desktop", "tablet", "mobile"] as const;
+export type DeviceType = (typeof DEVICE_TYPES)[number];
+
+/**
+ * One row of the list, as JSON delivers it.
+ *
+ * `expiresAt` and `lastSeen` are declared as `Date | string` because both are
+ * true: the route's schema says `z.date()` and a Next.js Server Component that
+ * awaited the fetcher is handed exactly that, while anything that crossed the
+ * wire as JSON - the browser fetch, and the dehydrated SSR payload a TanStack
+ * Start page rehydrates - has an ISO string. `DateFormat` accepts either, which
+ * is why this is a widened type rather than a normalisation step.
+ */
+export interface Device {
+ browser: string;
+ deviceType: DeviceType;
+ expiresAt: Date | string;
+ ipAddress: string;
+ /**
+ * Whether this row is the session doing the asking.
+ *
+ * The API decides it, by comparing each row's `publicId` to the device cookie
+ * on the request - so it is a property of *this* request rather than of the
+ * device, and it is the reason the cookie has to reach the API on both
+ * transports. A render that forwarded no cookie would mark every row
+ * `isCurrent: false` and offer to revoke the session doing the rendering.
+ *
+ * `DELETE /users/devices/{publicId}` refuses that with a `400` regardless, so
+ * this flag is what the list uses to not offer the button - not the rule
+ * itself. See {@link isRevokableDevice}.
+ */
+ isCurrent: boolean;
+ lastSeen: Date | string;
+ os: string;
+ publicId: string;
+}
+
+/** The list route's whole response. */
+export interface DevicesApi {
+ devices: Device[];
+}
+
+/**
+ * The list, as arguments to whichever fetcher is carrying it.
+ *
+ * No parameters at all: the route takes none, and derives whose devices these
+ * are from the session cookie.
+ *
+ * Worth reading against {@link devicesQueryKey}, which *does* carry a user id.
+ * The two are not in tension - the key says which cache slot an answer is filed
+ * under, this says what is asked for, and only the cookie says whose devices
+ * come back. Adding an owner here would move authorization onto a value the
+ * browser supplies.
+ */
+export const devicesRequest = () =>
+ ({
+ method: "get" as const,
+ module: "users" as const,
+ path: "/devices" as const,
+ }) as const;
+
+/** How the list is actually fetched. See {@link devicesQueryOptions}. */
+export type DevicesFetcher = () => Promise;
+
+/** The `name` every {@link DevicesRequestError} carries. See below. */
+const DEVICES_REQUEST_ERROR = "DevicesRequestError";
+
+/**
+ * The devices API refused, and this is what it refused with.
+ *
+ * A thrown error rather than a returned one, because the alternative is the bug
+ * this class exists to prevent. `getDevicesApi()` - the module this replaces -
+ * called `res.json()` on whatever came back, and a `401`, `403` or `429` body
+ * parses perfectly happily; read as a list it has no `devices`, so the page
+ * rendered "No active devices." A visitor whose session had just ended, or who
+ * had tripped the rate limiter, was told they were signed in nowhere - which is
+ * the single most alarming thing this page can say, and it was saying it about
+ * an outage.
+ *
+ * `status` is on the error rather than folded into the message so a caller can
+ * tell the finite cases apart without parsing English: `401` and `403` mean the
+ * session ended or was never allowed - the route guard is a navigation rule, not
+ * the boundary, so this is the *authorization* answer and it can arrive on a
+ * page the guard already let through. `429` is the rate limiter. A `500` never
+ * reaches here at all: `rawApiFetch` throws on those with the body attached.
+ *
+ * Deliberately *not* a redirect to the login page. A failed read is not a
+ * signed-out visitor - the same rule `#/lib/session` states at length - and the
+ * guard on the route already owns that decision from the one canonical session
+ * entry. Turning every API failure into a sign-out is how a rate limit becomes a
+ * logout.
+ *
+ * Recognised by `name` rather than by `instanceof`, and that is not fussiness.
+ * `@vitnode/core` is imported from `dist` by the apps and from `src` by its own
+ * tests, so two copies of this class can exist in one process and `instanceof`
+ * would answer `false` across them.
+ */
+export class DevicesRequestError extends Error {
+ constructor(status: number) {
+ super(`The devices API answered ${status} for the current user's devices.`);
+ this.name = DEVICES_REQUEST_ERROR;
+ this.status = status;
+ }
+
+ readonly status: number;
+}
+
+export const isDevicesRequestError = (
+ error: unknown,
+): error is DevicesRequestError =>
+ error instanceof Error && error.name === DEVICES_REQUEST_ERROR;
+
+/**
+ * The list, fetched from the browser.
+ *
+ * `fetcherClient` builds the same same-origin `/api/@vitnode/core/users/devices`
+ * URL every other VitNode client call uses, so the browser attaches the session
+ * and device cookies itself - which is what makes `isCurrent` correct - and a
+ * `429` is routed to the global rate-limit notice on the way through.
+ */
+export const fetchDevicesInBrowser: DevicesFetcher = async () => {
+ const response = await fetcherClient(usersModuleRef, devicesRequest());
+
+ if (!response.ok) throw new DevicesRequestError(response.status);
+
+ return await response.json();
+};
+
+/**
+ * The cache entry one visitor's list reads and writes, and the target an
+ * invalidation names.
+ *
+ * A factory over the owner's id rather than the constant `["devices", "me"]` it
+ * replaces. The reasoning was wrong in one specific way and it is worth keeping
+ * the correction visible: it argued that the request carries no user, so the key
+ * needs none, and that "the QueryClient is per request on the server and per
+ * browser on the client, so there is no client holding two visitors' lists".
+ *
+ * The last clause is the mistake. *Per browser* is not per visitor - the browser
+ * client is created once per document and outlives a sign-out:
+ *
+ * A signs in -> /settings/devices -> ["devices","me"] holds A's devices
+ * A signs out
+ * B signs in -> /settings/devices -> the loader asks for the same entry
+ *
+ * which is already populated, and with `refetchOnMount` off nothing refetches
+ * it. B would be shown A's operating systems, browsers and IP addresses without
+ * a single request being made - so Hono never sees the read it would have
+ * refused. Keyed by owner, B's entry is empty and the fetch happens.
+ *
+ * The locale is deliberately absent. Operating system, browser, IP address and
+ * both timestamps are the same data in every language; the only translated
+ * things on the page are the labels and the relative date, which the renderer
+ * resolves from the provider it is under. A locale in the key would mean a
+ * language switch silently refetched a list that had not changed.
+ *
+ * ## The id addresses a cache, it does not identify a caller
+ *
+ * `GET /users/devices` still takes no parameters and still derives the user from
+ * the session cookie - {@link devicesRequest} is unchanged. So this id decides
+ * which cache slot the answer is filed under and authorizes nothing; sending it
+ * would turn a cache key into an access-control parameter, which is the one
+ * thing it must never become.
+ *
+ * There is one entry per visitor and it has no sub-keys, so this is both the key
+ * and the family an invalidation names.
+ */
+export const devicesQueryKey = (userId: number) =>
+ ["devices", "user", userId] as const;
+
+/**
+ * The visitor's devices, as the one query definition every caller shares.
+ *
+ * A route loader warms it before the component renders:
+ *
+ * context.queryClient.ensureQueryData(
+ * devicesQueryOptions({ fetchDevices, userId }),
+ * )
+ *
+ * and the component reads the very same options back:
+ *
+ * const { data } = useSuspenseQuery(devicesQuery(userId))
+ *
+ * Same key, same request, same status checking - so the loader's list is the
+ * list the component renders, and a revoke that invalidates
+ * {@link devicesQueryKey} refetches through the identical contract.
+ *
+ * `userId` addresses the cache and nothing else - see {@link devicesQueryKey}.
+ * It is required, and the whole parameter object with it, because there is no
+ * honest default: falling back to a shared entry is the bug this closes. Both
+ * callers take it from the one place that knows it, the `_authenticated` route
+ * context, so the loader and the component cannot land on two partitions.
+ *
+ * `fetchDevices` is the seam. It defaults to the browser's fetcher, which is what
+ * a hydrated page wants; an app that also fetches during SSR passes one that can
+ * do both. It is a plain async function rather than anything framework-shaped, so
+ * nothing about this module knows which framework is rendering it.
+ *
+ * ## It asks once
+ *
+ * `retry: false`, against Query's default of three attempts. Every failure this
+ * read can produce is made worse by repeating it: a `429` is answered by sending
+ * the same request two more times, which is the thing the limiter is asking this
+ * app to stop doing, and a `401` is not going to become a `200` because we asked
+ * again. The visitor retries by reloading - a decision they can make and a rate
+ * limiter can see coming.
+ *
+ * No `staleTime`. Freshness is whatever the API's own caching gives, plus
+ * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both
+ * off), so a hydrated list is not refetched behind the reader; a revoke is what
+ * makes it stale, explicitly.
+ */
+export const devicesQueryOptions = ({
+ fetchDevices = fetchDevicesInBrowser,
+ userId,
+}: {
+ fetchDevices?: DevicesFetcher;
+ userId: number;
+}) =>
+ queryOptions({
+ // `userId` is deliberately absent from the request: the owner comes from
+ // the session cookie, on the server, on every call.
+ queryFn: async () => await fetchDevices(),
+ queryKey: devicesQueryKey(userId),
+ retry: false,
+ });
+
+/**
+ * What the shared list accepts, and the reason it accepts only this.
+ *
+ * Typed as the factory's own return type on purpose: a caller cannot hand the
+ * list a hand-rolled options object that happens to type-check, so "one query
+ * definition" is enforced by the compiler rather than by review.
+ */
+export type DevicesQueryOptions = ReturnType;
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts b/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts
new file mode 100644
index 000000000..ec9bcbe00
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts
@@ -0,0 +1,210 @@
+import { fetcherClient } from "@/lib/fetcher-client";
+
+import type { Device } from "./devices-query";
+
+import { usersModuleRef } from "./devices-query";
+
+/**
+ * Signing one device out, as a contract both frameworks satisfy.
+ *
+ * The API already accepts an authenticated `DELETE` from anywhere: it derives the
+ * user from the session cookie, scopes the lookup to their own sessions, and
+ * refuses the device the request itself is coming from. So the browser calls it
+ * directly - same origin, cookie attached by the browser itself - and there is
+ * deliberately no server function in between. A server function here would be a
+ * `POST` back to the app that then calls Hono, which is two round trips and a
+ * second place to get the semantics wrong, in exchange for nothing: this
+ * mutation needs no server-only secret, and it sets no cookie that would have to
+ * be copied onto a response.
+ *
+ * The Next.js app keeps its server action, which is not a contradiction. There
+ * the revoke has to end with `revalidatePath`, and that only exists on a server;
+ * see `revoke-action.server.ts`. What both sides share is the *shape* - the
+ * callback type below, the request, the status mapping and the refresh rule - so
+ * one list component can be handed either.
+ *
+ * ## What it cannot do
+ *
+ * Revoke the current device. `DELETE /users/devices/{publicId}` compares the id
+ * to the requester's own device cookie and answers `400` before it deletes
+ * anything, so there is no path through this module that can end the session
+ * making the call. That is why nothing here touches the session cache: the one
+ * mutation that would invalidate it is the one the API refuses. See
+ * {@link isRevokableDevice} and {@link REVOKE_CURRENT_DEVICE_STATUS}.
+ *
+ * The guard has no gap, and that is worth stating because "the device cookie was
+ * missing, so no row was current" would be one. `SessionModel.getUser()` - which
+ * is what fills `c.get("user")` for every request - resolves the device from that
+ * same cookie and looks the session up by `(token, deviceId)`. A request with no
+ * usable device cookie therefore has no user at all and is answered `401` before
+ * either route reads the cookie. So on every response these two routes can
+ * actually produce, the cookie names the device holding the requesting session:
+ * exactly one row is `isCurrent`, and it is precisely the one that cannot be
+ * revoked.
+ */
+
+/** Signing out one device. The id is the row's own `publicId`. */
+export interface RevokeDeviceArgs {
+ publicId: string;
+}
+
+/**
+ * The finite outcome of one revoke.
+ *
+ * A closed result rather than a rejection, so a Next.js server action and a
+ * browser fetch are the same prop: the caller is standing in a confirm dialog
+ * and has to say something either way. `status` carries which refusal it was,
+ * because the three that matter read differently - see
+ * {@link REVOKE_CURRENT_DEVICE_STATUS}.
+ */
+export interface RevokeDeviceResult {
+ data?: true;
+ error?: {
+ status: number;
+ };
+}
+
+/**
+ * What the shared list is handed instead of a mutation.
+ *
+ * A plain async function returning a closed result. Nothing framework-shaped
+ * survives in either direction.
+ */
+export type RevokeDevice = (
+ args: RevokeDeviceArgs,
+) => Promise;
+
+/**
+ * The status the API answers when asked to revoke the device doing the asking.
+ *
+ * Named rather than spelled `400` at the call site because it is the one refusal
+ * with a meaning instead of a cause: the request was well-formed and the device
+ * exists, and the answer is "not that one". The list does not offer the button
+ * for it, so reaching this means the row was stale - the same device cookie was
+ * re-issued, or another tab signed in - and the honest repair is to refetch,
+ * which is what {@link shouldRefreshAfterRevoke} does.
+ */
+export const REVOKE_CURRENT_DEVICE_STATUS = 400;
+
+/**
+ * Whether a row may be signed out at all.
+ *
+ * The current device may not, and the API is the one enforcing it. This is the
+ * *display* half of that rule, kept next to the request so the two cannot drift:
+ * a list that offered the button anyway would put a `400` behind it, and the only
+ * thing the person would learn is that something went wrong.
+ */
+export const isRevokableDevice = (device: Pick): boolean =>
+ !device.isCurrent;
+
+/**
+ * The public ids this module will send, and the shape of one it will not.
+ *
+ * `randomBytes(16).toString("hex")` is what `DeviceModel` mints, so a real id is
+ * 32 hex characters; the pattern is deliberately wider than that - any URL-safe
+ * token up to 128 characters - so a deployment whose ids were minted by an
+ * earlier scheme keeps working. What it rules out is the two shapes that are
+ * never an id and would be sent into a path segment: empty, and anything
+ * carrying `/`, `.` or a percent-escape.
+ *
+ * Refusing locally rather than letting the API answer is the point. The route's
+ * own `z.string()` accepts `""` and `../session`, and the fetcher interpolates
+ * the value into `/devices/{publicId}` - so an empty id addresses the *list*
+ * route with a `DELETE` and a traversal addresses a sibling. Both come back as
+ * some other status, which the dialog would report as a mysterious failure.
+ */
+const DEVICE_PUBLIC_ID = /^[A-Za-z0-9_-]{1,128}$/;
+
+export const isDevicePublicId = (publicId: string): boolean =>
+ DEVICE_PUBLIC_ID.test(publicId);
+
+/**
+ * One revoke, as arguments to whichever fetcher is carrying it.
+ *
+ * Shared with the Next.js server action, so a revoke is the same request in both
+ * applications rather than two places that merely look alike.
+ */
+export const revokeDeviceRequest = ({ publicId }: RevokeDeviceArgs) =>
+ ({
+ args: { params: { publicId } },
+ method: "delete" as const,
+ module: "users" as const,
+ path: "/devices/{publicId}" as const,
+ }) as const;
+
+/**
+ * The result a refused status becomes.
+ *
+ * Its own function because both transports have to agree on it, and because
+ * "which statuses count as done" is the kind of rule that grows a second
+ * spelling the moment it is inlined twice. `200` is the only success the route
+ * declares - it answers with an empty body - and everything else is the status,
+ * verbatim, for the caller to phrase.
+ */
+export const revokeResultFromStatus = (status: number): RevokeDeviceResult =>
+ status === 200 ? { data: true } : { error: { status } };
+
+/**
+ * Signs one device out from the browser.
+ *
+ * Never rejects, and that is the contract rather than an oversight. Every way
+ * this can fail is something the person has to be told in the dialog they are
+ * standing in, and a rejected promise would have to be caught by every caller to
+ * say the same thing.
+ *
+ * The `catch` is why the `500` case is not special: `rawApiFetch` throws on those
+ * with the failing URL and the server's own error text attached, and that throw
+ * is a server error like any other - reported as `status: 500`, not as a crashed
+ * dialog.
+ *
+ * A locally-refused id is reported as `400`, which is both the honest status -
+ * the request was malformed, and never sent - and the same one the route's own
+ * schema would have produced had it been. It coincides with
+ * {@link REVOKE_CURRENT_DEVICE_STATUS} and that costs nothing: both mean the row
+ * on screen does not match the server, and both are answered by refetching.
+ */
+export const revokeDeviceInBrowser: RevokeDevice = async ({ publicId }) => {
+ if (!isDevicePublicId(publicId)) return { error: { status: 400 } };
+
+ try {
+ const response = await fetcherClient(usersModuleRef, {
+ ...revokeDeviceRequest({ publicId }),
+ options: { credentials: "include" },
+ });
+
+ return revokeResultFromStatus(response.status);
+ } catch {
+ return { error: { status: 500 } };
+ }
+};
+
+/**
+ * Whether a finished revoke changed what the list is showing.
+ *
+ * Two cases, and the second is the one worth stating:
+ *
+ * - **It worked.** The row is gone, so the list is stale.
+ * - **`404`, or `400`.** The row was already wrong. A device somebody else
+ * revoked first is a `404`, and a row the list believed was revokable but the
+ * API considers current is a `400` - in both cases what is on screen does not
+ * match the server, and refetching is the repair.
+ *
+ * A `401`, `403`, `429` or `500` is deliberately *not* a refresh. Nothing was
+ * deleted, and the refetch would be a second request into whatever refused the
+ * first - a rate limiter answered by immediately asking again, or an ended
+ * session answered by a second `401` that blanks the list the person is looking
+ * at. The dialog says it failed and the list stays exactly as it was.
+ *
+ * The Next.js action applies the same rule before it calls `revalidatePath`, so
+ * both frameworks refresh on the same condition.
+ */
+export const shouldRefreshAfterRevoke = ({
+ data,
+ error,
+}: RevokeDeviceResult): boolean => {
+ if (data) return true;
+
+ return (
+ error?.status === 404 || error?.status === REVOKE_CURRENT_DEVICE_STATUS
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts b/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts
index cfcdc5341..d8abad739 100644
--- a/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts
+++ b/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts
@@ -5,25 +5,45 @@ import { revalidatePath } from "next/cache";
import { usersModule } from "@/api/modules/users/users.module";
import { fetcher } from "@/lib/fetcher";
-export const revokeDeviceAction = async ({
- publicId,
-}: {
- publicId: string;
-}): Promise<{ data?: true; error?: { status: number } }> => {
- const res = await fetcher(usersModule, {
- path: "/devices/{publicId}",
- method: "delete",
- module: "users",
- args: {
- params: { publicId },
- },
- });
-
- if (res.status !== 200) {
- return { error: { status: res.status } };
- }
+import type { RevokeDevice } from "./devices-revoke";
+
+import {
+ isDevicePublicId,
+ revokeDeviceRequest,
+ revokeResultFromStatus,
+ shouldRefreshAfterRevoke,
+} from "./devices-revoke";
+
+/**
+ * Signing one device out, from a Next.js page.
+ *
+ * The Next.js half of the revoke, and the only part of it that is Next.js's: the
+ * request, the id check and the status mapping all come from `devices-revoke.ts`,
+ * which is also what the TanStack Start app's browser fetch is built from. So a
+ * revoke means the same thing in both applications, and the `RevokeDevice` type
+ * this satisfies is the prop the shared button takes.
+ *
+ * What remains here is `revalidatePath`, which exists only on a server and is how
+ * a Next.js page refreshes. Its TanStack Start counterpart is a query
+ * invalidation of `DEVICES_QUERY_KEY`; both are applied on the same condition -
+ * `shouldRefreshAfterRevoke` - so neither refreshes a list the API left
+ * untouched. A `429` answered by re-rendering the page would send the same read
+ * straight back into the limiter, and a `401` would replace the list with a
+ * not-found while the person is reading it.
+ *
+ * The layout, not the page: revoking a device changes the sessions the header and
+ * the sidebar are rendered from as well as the list, and `'layout'` is what the
+ * previous version already said.
+ */
+export const revokeDeviceAction: RevokeDevice = async ({ publicId }) => {
+ if (!isDevicePublicId(publicId)) return { error: { status: 400 } };
- revalidatePath("/[locale]/(main)", "layout");
+ const res = await fetcher(usersModule, revokeDeviceRequest({ publicId }));
+ const result = revokeResultFromStatus(res.status);
+
+ if (shouldRefreshAfterRevoke(result)) {
+ revalidatePath("/[locale]/(main)", "layout");
+ }
- return { data: true };
+ return result;
};
diff --git a/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx b/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
index 060c58a40..bd28eba97 100644
--- a/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
+++ b/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
@@ -1,18 +1,42 @@
"use client";
import { LogOutIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import { toast } from "sonner";
+import { useTranslations } from "use-intl";
import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog";
import { Button } from "@/components/ui/button";
-import { revokeDeviceAction } from "./revoke-action.server";
+import type { RevokeDevice } from "./devices-revoke";
+/**
+ * Signing one device out, as a button both frameworks render.
+ *
+ * What used to make this Next.js-only was one import: the server action, which
+ * ends in `revalidatePath` and drags `next/headers` and the whole API module
+ * graph behind it. It is a prop now - `onRevoke` - so the Next.js page passes
+ * the action and the TanStack Start route passes a browser fetch that ends in a
+ * query invalidation, and everything visible here is the same in both.
+ *
+ * `useTranslations` from `use-intl` rather than from `next-intl`, for the same
+ * reason: `next-intl`'s root entry re-exports these APIs and is framework-free,
+ * but naming it here would be one more thing a non-Next app has to happen to
+ * resolve. The strings come from whichever provider is above - `I18nProvider` in
+ * Next.js, `RouteMessages` in TanStack Start - and both mount `core.global`
+ * alongside `core.auth.settings`, which is what the confirm dialog's own buttons
+ * need.
+ *
+ * The result is *reported*, never thrown. `onRevoke` returns a closed
+ * `RevokeDeviceResult` in both applications, so this component's whole error
+ * handling is one branch, and it stays identical whether the failure was a
+ * refused status or a server that was not listening.
+ */
export const RevokeDeviceButton = ({
+ onRevoke,
os,
publicId,
}: {
+ onRevoke: RevokeDevice;
os: string;
publicId: string;
}) => {
@@ -23,7 +47,8 @@ export const RevokeDeviceButton = ({
{
- const result = await revokeDeviceAction({ publicId });
+ const result = await onRevoke({ publicId });
+
if (result.error) {
toast.error(tGlobal("title"), {
description: tGlobal("internal_server_error"),
diff --git a/packages/vitnode/src/views/auth/settings/nav-content.tsx b/packages/vitnode/src/views/auth/settings/nav-content.tsx
new file mode 100644
index 000000000..92a3b47d3
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/nav-content.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import {
+ ChevronRightIcon,
+ KeyRoundIcon,
+ MonitorSmartphoneIcon,
+ UserRoundIcon,
+} from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+import type { AuthLinkComponent } from "../auth-link";
+import type { SettingsNavKey } from "./settings-nav";
+
+import { isSettingsNavItemActive, SETTINGS_NAV_ITEMS } from "./settings-nav";
+
+/**
+ * The settings navigation, with the two things it cannot resolve for itself
+ * handed in.
+ *
+ * `pathname` rather than a hook, and `LinkComponent` rather than an import: both
+ * are the same seam `HeaderContent` and `SearchFeedContent` already draw, and
+ * both exist for the same reason. `usePathname` and a locale-aware `Link` come
+ * from `next-intl` in the Next.js app and from the router in TanStack Start, and
+ * importing either here would make this module Next-only - which is exactly what
+ * `views/auth/auth-boundaries.test.ts` pins.
+ *
+ * The pathname is *internal* - no locale prefix. Each framework's wrapper hands
+ * over the spelling its own router uses, and nothing here localizes an href
+ * either: `LinkComponent` does that, once.
+ */
+const ICONS: Record = {
+ devices: MonitorSmartphoneIcon,
+ overview: UserRoundIcon,
+ security: KeyRoundIcon,
+};
+
+export const SettingsNavContent = ({
+ LinkComponent,
+ pathname,
+}: {
+ LinkComponent: AuthLinkComponent;
+ pathname: string;
+}) => {
+ const t = useTranslations("core.auth.settings.nav");
+
+ return (
+
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/nav.tsx b/packages/vitnode/src/views/auth/settings/nav.tsx
index 5a0d5cd6a..6d4ceaceb 100644
--- a/packages/vitnode/src/views/auth/settings/nav.tsx
+++ b/packages/vitnode/src/views/auth/settings/nav.tsx
@@ -1,60 +1,18 @@
"use client";
-import {
- ChevronRightIcon,
- KeyRoundIcon,
- MonitorSmartphoneIcon,
- UserRoundIcon,
-} from "lucide-react";
-import { useTranslations } from "next-intl";
+import { usePathname } from "@/lib/navigation";
-import { buttonVariants } from "@/components/ui/button";
-import { Link, usePathname } from "@/lib/navigation";
-import { cn, normalizeUrl } from "@/lib/utils";
+import { NextAuthLink } from "../next-link";
+import { SettingsNavContent } from "./nav-content";
-const items = [
- {
- href: "/settings/overview",
- key: "overview",
- icon: UserRoundIcon,
- aliases: ["/settings"],
- },
- {
- href: "/settings/devices",
- key: "devices",
- icon: MonitorSmartphoneIcon,
- },
- { href: "/settings/security", key: "security", icon: KeyRoundIcon },
-] as const;
-
-export const NavSettings = () => {
- const t = useTranslations("core.auth.settings.nav");
- const pathname = normalizeUrl(usePathname());
-
- return (
-
- );
-};
+/**
+ * {@link SettingsNavContent}, wired to Next.js.
+ *
+ * The two framework-specific halves and nothing else: `next-intl`'s locale-aware
+ * `usePathname`, which answers with the internal path the route tree uses, and
+ * the same `Link` every other auth screen renders. Which items exist and which
+ * one is selected is `settings-nav.ts`, shared.
+ */
+export const NavSettings = () => (
+
+);
diff --git a/packages/vitnode/src/views/auth/settings/overview/overview.tsx b/packages/vitnode/src/views/auth/settings/overview/overview.tsx
index 629981645..1e5530083 100644
--- a/packages/vitnode/src/views/auth/settings/overview/overview.tsx
+++ b/packages/vitnode/src/views/auth/settings/overview/overview.tsx
@@ -1,9 +1,27 @@
-import { getTranslations } from "next-intl/server";
+"use client";
+
+import { useTranslations } from "use-intl";
import { HeaderContent } from "@/components/ui/header-content";
-export const OverviewSettings = async () => {
- const t = await getTranslations("core.auth.settings.nav");
+/**
+ * The overview panel, which is currently a heading.
+ *
+ * Rendered by two URLs in each framework: `/settings`, whose root screen shows
+ * the overview rather than redirecting to it, and `/settings/overview`. See
+ * `SETTINGS_NAV_ITEMS` for why the root is an alias and not a redirect.
+ *
+ * A client component reading `use-intl` rather than a Server Component reading
+ * `next-intl/server`, which is what lets a TanStack Start route render it: the
+ * strings come from whichever provider is above it - `I18nProvider` in Next.js,
+ * `RouteMessages` in TanStack Start - and both mount `core.auth.settings`.
+ *
+ * There is deliberately nothing else here. Profile editing, email changes and
+ * the rest are not features VitNode has yet, and the route name is not a
+ * specification.
+ */
+export const OverviewSettings = () => {
+ const t = useTranslations("core.auth.settings.nav");
return ;
};
diff --git a/packages/vitnode/src/views/auth/settings/security/security.tsx b/packages/vitnode/src/views/auth/settings/security/security.tsx
index 02616452c..5cde30e18 100644
--- a/packages/vitnode/src/views/auth/settings/security/security.tsx
+++ b/packages/vitnode/src/views/auth/settings/security/security.tsx
@@ -1,9 +1,23 @@
-import { getTranslations } from "next-intl/server";
+"use client";
+
+import { useTranslations } from "use-intl";
import { HeaderContent } from "@/components/ui/header-content";
-export const SecuritySettings = async () => {
- const t = await getTranslations("core.auth.settings.nav");
+/**
+ * The security panel, which is currently a heading.
+ *
+ * A client component reading `use-intl` rather than a Server Component reading
+ * `next-intl/server`, for the reason `OverviewSettings` explains: it is rendered
+ * by a Next.js page and by a TanStack Start route, and only one of those has a
+ * request scope.
+ *
+ * Passwords, two-factor enrolment, passkeys and a session log are not features
+ * VitNode has yet. This file is what `/settings/security` does today, and the
+ * route name is not a specification.
+ */
+export const SecuritySettings = () => {
+ const t = useTranslations("core.auth.settings.nav");
return ;
};
diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.ts b/packages/vitnode/src/views/auth/settings/settings-nav.ts
new file mode 100644
index 000000000..1df8e4f27
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/settings-nav.ts
@@ -0,0 +1,98 @@
+import { normalizeUrl } from "@/lib/utils";
+
+/**
+ * The settings screens, as data rather than as markup.
+ *
+ * Two decisions live here and nowhere else: which panels the settings navigation
+ * offers, and which one of them a given path is on. Both are plain functions
+ * over strings - no router, no request, no React - because both frameworks have
+ * to reach the same answer from the URL each of them happens to hold, and a
+ * highlighted nav item disagreeing with the panel on screen is the kind of bug
+ * that only shows up on one of the two.
+ *
+ * What this is *not*: a route table. Neither framework learns which routes exist
+ * from this file - Next.js has `routes/main/settings/*` and TanStack Start has
+ * `routes/_main/_authenticated/settings/*`, and a panel that is not routed
+ * simply renders a link to a 404. The list is the navigation's contents, which
+ * is a product decision, and it is shared so the two navigations cannot offer
+ * different menus.
+ */
+
+/** Where the settings screens are rooted, and the mobile "back" destination. */
+export const SETTINGS_ROOT_HREF = "/settings";
+
+export type SettingsNavKey = "devices" | "overview" | "security";
+
+export interface SettingsNavItem {
+ /**
+ * Paths that light this item up without being its own href.
+ *
+ * `/settings` is the only one, and it exists because the root path renders the
+ * overview panel rather than redirecting to it - see the note on
+ * {@link SETTINGS_NAV_ITEMS}. Without the alias, the root screen would show a
+ * navigation with nothing selected.
+ */
+ aliases: readonly string[];
+ href: string;
+ /** The `core.auth.settings.nav` key this item's label comes from. */
+ key: SettingsNavKey;
+}
+
+/**
+ * The settings navigation, in the order it is rendered.
+ *
+ * `/settings` is an alias of the overview panel rather than a redirect to it,
+ * and that is deliberate on both sides of the seam. The shell shows the
+ * navigation *instead of* the panel on a narrow screen (see
+ * {@link isSettingsRootPath}), so a visitor who lands on `/settings` from a
+ * phone is looking at a menu; redirecting them to `/settings/overview` would
+ * skip the menu entirely and leave the back link as the only way to reach it.
+ */
+export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [
+ {
+ aliases: [SETTINGS_ROOT_HREF],
+ href: "/settings/overview",
+ key: "overview",
+ },
+ { aliases: [], href: "/settings/devices", key: "devices" },
+ { aliases: [], href: "/settings/security", key: "security" },
+];
+
+/**
+ * Whether `pathname` is the settings root.
+ *
+ * The pathname must already be *internal* - no locale prefix. Next.js gets that
+ * from `next-intl`'s `usePathname`, TanStack Start from a router location the
+ * Stage 3 rewrite has stripped. Nothing here localizes anything, and nothing
+ * here may start to: a rule that compared against `/pl/settings` would be a
+ * second copy of the locale routing.
+ */
+export const isSettingsRootPath = (pathname: string): boolean =>
+ normalizeUrl(pathname) === SETTINGS_ROOT_HREF;
+
+/** Whether one navigation item is the panel `pathname` is showing. */
+export const isSettingsNavItemActive = (
+ item: SettingsNavItem,
+ pathname: string,
+): boolean =>
+ [item.href, ...item.aliases].some(
+ href => normalizeUrl(href) === normalizeUrl(pathname),
+ );
+
+/**
+ * Which panel `pathname` is on, or nothing.
+ *
+ * `undefined` for a path outside the settings screens, and for a settings path
+ * with no navigation entry - a future panel reachable by URL before it is
+ * listed. The navigation renders nothing selected in both cases, which is the
+ * honest answer.
+ */
+export const activeSettingsNavKey = (
+ pathname: string,
+): SettingsNavKey | undefined =>
+ SETTINGS_NAV_ITEMS.find(item => isSettingsNavItemActive(item, pathname))?.key;
+
+/** One navigation item's own href, by key. */
+export const settingsNavHref = (key: SettingsNavKey): string =>
+ SETTINGS_NAV_ITEMS.find(item => item.key === key)?.href ??
+ `${SETTINGS_ROOT_HREF}/${key}`;
diff --git a/packages/vitnode/src/views/auth/settings/shell-content.tsx b/packages/vitnode/src/views/auth/settings/shell-content.tsx
new file mode 100644
index 000000000..ba8b21bb3
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/shell-content.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { ArrowLeftIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { buttonVariants } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { HeaderContent } from "@/components/ui/header-content";
+import { cn } from "@/lib/utils";
+
+import type { AuthLinkComponent } from "../auth-link";
+
+import { SETTINGS_ROOT_HREF } from "./settings-nav";
+
+/**
+ * The settings screens' frame: the heading, the navigation card, and the panel
+ * every settings page renders inside.
+ *
+ * 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 layout route renders exactly the frame the Next.js layout
+ * renders.
+ *
+ * Two things arrive from outside, and they are the only two:
+ *
+ * - `nav`, a slot. Each framework builds its own navigation because each has its
+ * own `Link` and its own way of knowing where it is; what the menu *contains*
+ * is shared, in `settings-nav.ts`.
+ * - `BackLink`, a component. The mobile back link's markup is presentation and
+ * stays here, so the two frameworks cannot drift into two different buttons -
+ * only the anchor underneath it differs.
+ *
+ * ## `isRoot` is a prop, not a hook call
+ *
+ * The whole of the mobile behaviour: on a narrow screen `/settings` shows the
+ * heading and the menu, and a panel path shows the panel with a link back to the
+ * menu. Both cards render in both cases and one of the two is hidden, so a
+ * desktop layout is one grid rather than two - which is why this is a class name
+ * rather than a branch.
+ *
+ * Deciding it needs the current path, which is the one thing this module must
+ * not read for itself (see {@link SettingsNavContent}). `isSettingsRootPath` in
+ * `settings-nav.ts` is the shared rule; each framework applies it to the
+ * pathname its own router holds.
+ */
+export const SettingsShellContent = ({
+ BackLink,
+ children,
+ isRoot,
+ nav,
+}: {
+ BackLink: AuthLinkComponent;
+ children: React.ReactNode;
+ isRoot: boolean;
+ nav: React.ReactNode;
+}) => {
+ const t = useTranslations("core.auth.settings");
+
+ return (
+
+
+
+
+
+ {nav}
+
+
+
+
+
+
+ {t("title")}
+
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/shell.tsx b/packages/vitnode/src/views/auth/settings/shell.tsx
index 78625dc04..559923814 100644
--- a/packages/vitnode/src/views/auth/settings/shell.tsx
+++ b/packages/vitnode/src/views/auth/settings/shell.tsx
@@ -1,59 +1,26 @@
"use client";
-import { ArrowLeftIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
-
-import { buttonVariants } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import { HeaderContent } from "@/components/ui/header-content";
-import { Link, usePathname } from "@/lib/navigation";
-import { cn, normalizeUrl } from "@/lib/utils";
+import { usePathname } from "@/lib/navigation";
+import { NextAuthLink } from "../next-link";
import { NavSettings } from "./nav";
-
-export const SettingsShell = ({ children }: { children: React.ReactNode }) => {
- const t = useTranslations("core.auth.settings");
- const isRoot = normalizeUrl(usePathname()) === "/settings";
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t("title")}
-
-
- {children}
-
-
-
-
- );
-};
+import { isSettingsRootPath } from "./settings-nav";
+import { SettingsShellContent } from "./shell-content";
+
+/**
+ * {@link SettingsShellContent}, wired to Next.js.
+ *
+ * Where Next.js enters the settings frame, and the only place it does: the
+ * pathname comes from `next-intl`, the back link is the shared auth `Link`, and
+ * the navigation is the Next.js wrapper. Everything visible is
+ * `shell-content.tsx`.
+ */
+export const SettingsShell = ({ children }: { children: React.ReactNode }) => (
+ }
+ >
+ {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 dfb64a5c5..e65b4f17c 100644
--- a/packages/vitnode/src/views/auth/sign-in/form/form.tsx
+++ b/packages/vitnode/src/views/auth/sign-in/form/form.tsx
@@ -16,9 +16,10 @@ import { SignInFormContent } from "./sign-in-form-content";
* 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.
+ * - **A `Link`** that knows how to write a locale prefix into an internal href -
+ * the "forgot your password" link, which points at `/login/reset-password`.
+ * That route is served by both applications now, and this wrapper is the
+ * Next.js one, so it links to the Next.js page as it always has.
*/
export const FormSignIn = ({
isAdmin,
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
index 561485268..0a2c4981c 100644
--- a/packages/vitnode/src/views/auth/sign-in/sign-in-card.tsx
+++ b/packages/vitnode/src/views/auth/sign-in/sign-in-card.tsx
@@ -18,7 +18,10 @@ import { SignInContent } from "./sign-in-content";
* 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.
+ * The "create an account" link points at `/register`, which is served by both
+ * applications now. This wrapper is the Next.js one, so it links to the Next.js
+ * page as it always has; the TanStack Start route hands `SignInContent` its own
+ * link component instead. See `AUTH_HREF` in `../auth-link.ts`.
*/
export const SignInCard = ({
form,
diff --git a/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx b/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx
index 35edb2085..18bbafa7d 100644
--- a/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx
+++ b/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx
@@ -1,6 +1,6 @@
import { CheckIcon, XIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import type { ItemAutoFormComponentProps } from "@/components/form/auto-form";
diff --git a/packages/vitnode/src/views/auth/sign-up/email-confirmation-view.tsx b/packages/vitnode/src/views/auth/sign-up/email-confirmation-view.tsx
index c1f3d6e9b..b67077f60 100644
--- a/packages/vitnode/src/views/auth/sign-up/email-confirmation-view.tsx
+++ b/packages/vitnode/src/views/auth/sign-up/email-confirmation-view.tsx
@@ -1,5 +1,5 @@
import { Mail, MailboxIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
+import { useTranslations } from "use-intl";
import {
Card,
diff --git a/packages/vitnode/src/views/auth/sign-up/form/form.tsx b/packages/vitnode/src/views/auth/sign-up/form/form.tsx
index 5a383f56e..ae9e0c518 100644
--- a/packages/vitnode/src/views/auth/sign-up/form/form.tsx
+++ b/packages/vitnode/src/views/auth/sign-up/form/form.tsx
@@ -2,113 +2,36 @@
import type { z } from "zod";
-import { useTranslations } from "next-intl";
-
import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
-import {
- AutoForm,
- type ItemAutoFormComponentProps,
-} from "@/components/form/auto-form";
-import { AutoFormCheckbox } from "@/components/form/fields/checkbox";
-import { AutoFormInput } from "@/components/form/fields/input";
-import { Link } from "@/lib/navigation";
-import { removeSpecialCharacters } from "@/lib/special-characters";
-
-import { PasswordInput } from "../components/password-input";
-import { useFormSignUp } from "./use-form";
-
+import { NextAuthLink } from "../../next-link";
+import { mutationApi } from "./mutation-api.server";
+import { SignUpFormContent } from "./sign-up-form-content";
+
+/**
+ * {@link SignUpFormContent}, wired to Next.js.
+ *
+ * The props are unchanged, so `SignUpView` 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 creates the account, keeps the
+ * session cookie the API may have minted, revalidates the layout the session
+ * is rendered into and redirects - all of which are Next.js APIs, and all of
+ * which stay on this side of the boundary.
+ * - **A `Link`** that knows how to write a locale prefix into an internal href,
+ * for the terms-and-conditions link inside the checkbox description.
+ */
export const FormSignUp = ({
- isEmail,
captcha,
+ isEmail,
}: {
captcha: z.infer["captcha"];
isEmail: boolean;
-}) => {
- const t = useTranslations("core.auth.sign_up");
- const { onSubmit, formSchema } = useFormSignUp();
-
- return (
- {
- const value: string = field.value ?? "";
-
- return (
-
-
- {value.length >= 3 && (
-
- {t.rich("username.your_user_code", {
- code: () => (
-
- {removeSpecialCharacters(value)}
-
- ),
- })}
-
- )}
-
- );
- },
- },
- {
- id: "email",
- component: props => (
-
- ),
- },
- {
- id: "password",
- component: props => (
-
- ),
- },
- {
- id: "terms",
- component: props => (
- (
-
- {text}
-
- ),
- })}
- label={t("terms.label")}
- />
- ),
- },
- ...(isEmail
- ? [
- {
- id: "newsletter" as const,
- component: (props: ItemAutoFormComponentProps) => (
-
- ),
- },
- ]
- : []),
- ]}
- formSchema={formSchema}
- mode="all"
- onSubmit={onSubmit}
- submitButtonProps={{
- className: "w-full",
- children: t("submit"),
- }}
- />
- );
-};
+}) => (
+
+);
diff --git a/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts b/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts
index 4c391645e..51d31f23d 100644
--- a/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts
+++ b/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts
@@ -1,19 +1,33 @@
"use server";
-import type { z } from "zod";
-
import { revalidatePath } from "next/cache";
-import type { zodSignUpSchema } from "@/api/modules/users/routes/sign-up.route";
-
import { usersModule } from "@/api/modules/users/users.module";
import { fetcher } from "@/lib/fetcher";
import { redirect } from "@/lib/navigation";
+import type { SignUpMutationResult, SignUpSubmitValues } from "./schema";
+
+import { signUpConflictReason } from "./schema";
+
+/**
+ * Registration for Next.js: create the account, then either land the visitor on
+ * the front page or hand the form back the reason it could not.
+ *
+ * `allowSaveCookies: true` is load bearing. On a deployment with no email
+ * adapter the API marks the account verified and mints a session on the *same*
+ * `201`, so the reply carries a `Set-Cookie` the browser has to keep - without
+ * it the visitor is registered and immediately anonymous.
+ *
+ * The answer is narrowed to {@link SignUpMutationResult} here rather than in the
+ * form: this is the only layer that sees the API's body, and the 409 message it
+ * writes (`"Email already exists"`) is an internal string that must not reach a
+ * screen.
+ */
export const mutationApi = async ({
captchaToken,
...input
-}: z.infer & { captchaToken: string }) => {
+}: SignUpSubmitValues): Promise => {
const res = await fetcher(usersModule, {
path: "/sign_up",
method: "post",
@@ -25,15 +39,25 @@ export const mutationApi = async ({
},
});
- if (res.status !== 201) {
- return { error: await res.text() };
+ if (res.status === 409) {
+ const conflict = signUpConflictReason(await res.text());
+
+ return {
+ message: conflict === "unknown" ? "Internal Server Error" : conflict,
+ };
}
+ if (res.status !== 201) return { message: "Internal Server Error" };
+
const data = await res.json();
- if (data.emailVerified) {
- revalidatePath("/[locale]/(main)", "layout");
- await redirect("/");
- }
- return { data };
+ if (!data.emailVerified) return { emailConfirmation: data.email };
+
+ revalidatePath("/[locale]/(main)", "layout");
+ await redirect("/");
+
+ // `redirect()` throws, so this is unreachable - it exists so the function's
+ // type is the closed union the shared form reads rather than
+ // `... | undefined` inferred from a fall-through.
+ return undefined;
};
diff --git a/packages/vitnode/src/views/auth/sign-up/form/schema.test.ts b/packages/vitnode/src/views/auth/sign-up/form/schema.test.ts
new file mode 100644
index 000000000..71774899f
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/form/schema.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createPasswordZodSchema,
+ createSignUpFormSchema,
+ signUpConflictReason,
+ signUpFormOutcome,
+} from "./schema";
+
+const messages = {
+ fieldRequired: "required",
+ invalidEmail: "not an email",
+ invalidPassword: "too weak",
+ nameMaxLength: "too long",
+ nameMinLength: "too short",
+ termsRequired: "tick the box",
+};
+
+const schema = createSignUpFormSchema(messages);
+
+const valid = {
+ email: "test@test.com",
+ name: "tester",
+ password: "Test123!",
+ terms: true,
+};
+
+describe("the sign-up schema", () => {
+ it("accepts a complete registration", () => {
+ const parsed = schema.safeParse(valid);
+
+ expect(parsed.success).toBe(true);
+ expect(parsed.data).toEqual({
+ email: "test@test.com",
+ name: "tester",
+ newsletter: false,
+ password: "Test123!",
+ terms: true,
+ });
+ });
+
+ it.each([
+ ["a name shorter than three characters", { name: "ab" }, "too short"],
+ ["a name longer than 32 characters", { name: "a".repeat(33) }, "too long"],
+ ["a value that is not an email address", { email: "test" }, "not an email"],
+ ["an unticked terms checkbox", { terms: false }, "tick the box"],
+ ])("rejects %s with the message it was given", (_case, patch, message) => {
+ const parsed = schema.safeParse({ ...valid, ...patch });
+
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.message).toBe(message);
+ });
+
+ it("defaults the fields AutoForm builds its initial values from", () => {
+ // A field without a default renders as an uncontrolled input. `email` is
+ // deliberately absent from this list: it has no default today, and
+ // `AutoFormInput` covers it with `value={field.value ?? ""}`.
+ expect(schema.shape.name.def.defaultValue).toBe("");
+ expect(schema.shape.password.def.defaultValue).toBe("");
+ expect(schema.shape.terms.def.defaultValue).toBe(false);
+ });
+
+ it("carries the messages it was built with, not a fixed language", () => {
+ const polish = createSignUpFormSchema({
+ ...messages,
+ invalidEmail: "nieprawidłowy adres e-mail",
+ });
+ const parsed = polish.safeParse({ ...valid, email: "test" });
+
+ expect(parsed.error?.issues[0]?.message).toBe("nieprawidłowy adres e-mail");
+ });
+});
+
+describe("the password rules", () => {
+ const password = createPasswordZodSchema({
+ fieldRequired: "required",
+ invalidPassword: "too weak",
+ });
+
+ it.each([
+ ["Test123!", true],
+ ["Sufficiently1Long!", true],
+ // Eight characters, an uppercase, a digit and a non-word character are all
+ // required - the four `.regex()` calls, one per row below.
+ ["Test12!", false],
+ ["test123!", false],
+ ["TestTest!", false],
+ ["Test1234", false],
+ ])("reads %s as acceptable: %s", (value, expected) => {
+ expect(password.safeParse(value).success).toBe(expected);
+ });
+
+ it("treats an underscore as a special character", () => {
+ // `\W|_` - an underscore is a word character, so it needs the second half.
+ expect(password.safeParse("Test123_").success).toBe(true);
+ });
+
+ it("says the same thing whichever rule failed", () => {
+ // The live checklist in `PasswordInput` is what says *which* rule; the
+ // message is the same one either way, which is why it is one string.
+ for (const value of ["short1A!", "nouppercase1!", "NoDigits!"]) {
+ const parsed = password.safeParse(value);
+ if (parsed.success) continue;
+
+ expect(parsed.error.issues[0]?.message).toBe("too weak");
+ }
+ });
+
+ it("requires the field, with the message it was given", () => {
+ expect(password.safeParse(undefined).success).toBe(true); // the default
+ expect(password.safeParse(42).error?.issues[0]?.message).toBe("required");
+ });
+});
+
+describe("classifying a 409", () => {
+ it.each([
+ ["Email already exists", "email_exists"],
+ ["Name already exists", "name_exists"],
+ // Case and surrounding whitespace are the API's business, not a reason to
+ // fall back to the generic failure.
+ [" name already exists ", "name_exists"],
+ ])("reads %s as %s", (body, expected) => {
+ expect(signUpConflictReason(body)).toBe(expected);
+ });
+
+ it.each([
+ ['{"error":"Email already exists"}', "email_exists"],
+ ['{"message":"Name already exists"}', "name_exists"],
+ ['"Email already exists"', "email_exists"],
+ ])("unwraps %s", (body, expected) => {
+ // Hono's bare `HTTPException` answers with the message as plain text, but
+ // VitNode's other conflict routes answer with JSON - both are recognised so
+ // a change on the API's side does not silently degrade to a toast.
+ expect(signUpConflictReason(body)).toBe(expected);
+ });
+
+ it.each([
+ "",
+ "Something else went wrong",
+ "Name code already exists",
+ "{}",
+ "[1,2,3]",
+ "not json {",
+ ])("reads %s as unknown rather than guessing a field", body => {
+ expect(signUpConflictReason(body)).toBe("unknown");
+ });
+});
+
+describe("what a submit result means for the screen", () => {
+ it("says nothing on success, which is how the form knows the caller is leaving", () => {
+ expect(signUpFormOutcome(undefined)).toBeNull();
+ });
+
+ it("swaps the card for the confirmation screen, carrying the address", () => {
+ expect(signUpFormOutcome({ emailConfirmation: "test@test.com" })).toEqual({
+ email: "test@test.com",
+ kind: "confirmation",
+ });
+ });
+
+ it.each([
+ ["email_exists", "email"],
+ ["name_exists", "name"],
+ ] as const)("marks the %s field", (message, field) => {
+ expect(signUpFormOutcome({ message })).toEqual({ field, kind: "field" });
+ });
+
+ it("renders anything else as the internal-error toast", () => {
+ expect(signUpFormOutcome({ message: "Internal Server Error" })).toEqual({
+ kind: "toast",
+ });
+ });
+});
diff --git a/packages/vitnode/src/views/auth/sign-up/form/schema.ts b/packages/vitnode/src/views/auth/sign-up/form/schema.ts
new file mode 100644
index 000000000..f422034d3
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/form/schema.ts
@@ -0,0 +1,230 @@
+import { z } from "zod";
+
+/**
+ * The registration form's shape and its failure vocabulary, with no React in
+ * sight.
+ *
+ * The same split `sign-in/form/schema.ts` makes, for the same reason: the schema
+ * is a function of already-translated strings, and the outcome mapping is a
+ * function of whatever the submit callback returned. Neither needs a renderer, a
+ * provider or a request to be checked - which matters more here than on the
+ * login form, because registration has four outcomes rather than two and one of
+ * them replaces the whole page.
+ */
+
+/** The password rules, as messages rather than as copy. */
+export interface PasswordFieldMessages {
+ /** Shown when the field is missing entirely. */
+ fieldRequired: string;
+ /** Shown for a password that fails any of the four character rules. */
+ invalidPassword: string;
+}
+
+export interface SignUpFormMessages extends PasswordFieldMessages {
+ /** Shown when the email field is not an email address. */
+ invalidEmail: string;
+ /** Shown when the username is longer than 32 characters. */
+ nameMaxLength: string;
+ /** Shown when the username is shorter than 3 characters. */
+ nameMinLength: string;
+ /** Shown when the terms checkbox is left unticked. */
+ termsRequired: string;
+}
+
+/**
+ * The password field, shared by registration and password recovery.
+ *
+ * Four separate `.regex()` calls carrying the *same* message, which is
+ * deliberate: `PasswordInput` renders a live checklist of the four rules from
+ * its own copies of these expressions, so the message a failing password
+ * produces is always "too weak" and the checklist is what says which rule.
+ * Collapsing them into one expression would change nothing on screen and lose
+ * the ability to say which rule a value breaks.
+ *
+ * The API is stricter than this only in that it accepts *less*: `zodSignUpSchema`
+ * asks for eight characters and nothing else, so every value this schema admits
+ * is one the API admits too.
+ */
+export const createPasswordZodSchema = ({
+ fieldRequired,
+ invalidPassword,
+}: PasswordFieldMessages) =>
+ z
+ .string({ message: fieldRequired })
+ .regex(/^.{8,}$/, invalidPassword)
+ .regex(/[A-Z]/, invalidPassword)
+ .regex(/\d/, invalidPassword)
+ .regex(/\W|_/, invalidPassword)
+ .default("");
+
+export const createSignUpFormSchema = ({
+ fieldRequired,
+ invalidEmail,
+ invalidPassword,
+ nameMaxLength,
+ nameMinLength,
+ termsRequired,
+}: SignUpFormMessages) =>
+ z.object({
+ email: z.email({ message: invalidEmail }),
+ name: z
+ .string({ message: fieldRequired })
+ .min(3, nameMinLength)
+ .max(32, nameMaxLength)
+ .default(""),
+ newsletter: z.boolean().default(false).optional(),
+ password: createPasswordZodSchema({ fieldRequired, invalidPassword }),
+ // Never sent to the API - it has no `terms` field. The tick is a local
+ // precondition, which is why it lives in the form schema and is dropped by
+ // the submit callback.
+ terms: z
+ .boolean()
+ .refine(value => value, termsRequired)
+ .default(false),
+ });
+
+export type SignUpFormSchema = ReturnType;
+export type SignUpFormValues = z.infer;
+
+/**
+ * What registration sends, once the form has dropped the parts the API has no
+ * field for.
+ *
+ * `terms` is absent on purpose - the tick is a local precondition, not something
+ * the API stores - and `captchaToken` is present because the sign-up route is
+ * `withCaptcha: true`, so a caller that could not attach one has nothing to
+ * send. Both are the reason this is its own type rather than
+ * {@link SignUpFormValues}.
+ */
+export interface SignUpSubmitValues {
+ captchaToken: string;
+ email: string;
+ name: string;
+ newsletter?: boolean;
+ password: string;
+}
+
+/**
+ * What the API told us about a registration attempt, as the UI cares about it.
+ *
+ * Four outcomes, because registration genuinely has four:
+ *
+ * - `undefined` - it worked *and* the caller has already navigated. The account
+ * was created with `emailVerified: true`, the API minted a session on the same
+ * response, and there is nothing left for the form to render.
+ * - `{ emailConfirmation }` - it worked and the visitor is *not* signed in: this
+ * deployment has an email adapter, so the account waits on a confirmation
+ * link. The address travels back because the confirmation screen prints it.
+ * - `{ message: 'email_exists' | 'name_exists' }` - a conflict the visitor can
+ * fix, and the two are distinguished because they mark different fields.
+ * - `{ message: 'Internal Server Error' }` - anything else, rendered as the
+ * internal-error toast.
+ *
+ * Spelled as literals the transport can produce rather than as the API's own
+ * body, so no backend string reaches a screen: the API answers a 409 with
+ * `"Email already exists"`, and classifying that text is the transport's job.
+ */
+export type SignUpMutationResult =
+ | undefined
+ | { emailConfirmation: string; message?: never }
+ | {
+ emailConfirmation?: never;
+ message: "email_exists" | "Internal Server Error" | "name_exists";
+ };
+
+/** Which field a conflict belongs to. */
+export type SignUpConflictField = "email" | "name";
+
+/**
+ * What a submit result means for the screen.
+ *
+ * - `"confirmation"` - swap the card for the "check your email" view.
+ * - `"field"` - mark one field and focus it; the hook supplies the message,
+ * because it is the half that has translations.
+ * - `"toast"` - the internal-error toast.
+ * - `null` - nothing to show: it worked and the caller navigated.
+ *
+ * A success is deliberately indistinguishable from "returned nothing", exactly
+ * as on the login form: both the Next.js server action and a TanStack Start
+ * mutation leave the page on the happy path, so the resolved value is
+ * `undefined` in both.
+ */
+export const signUpFormOutcome = (
+ result: SignUpMutationResult,
+):
+ | null
+ | { email: string; kind: "confirmation" }
+ | { field: SignUpConflictField; kind: "field" }
+ | { kind: "toast" } => {
+ if (!result) return null;
+
+ if (result.emailConfirmation) {
+ return { email: result.emailConfirmation, kind: "confirmation" };
+ }
+
+ if (result.message === "email_exists") {
+ return { field: "email", kind: "field" };
+ }
+ if (result.message === "name_exists") return { field: "name", kind: "field" };
+
+ return { kind: "toast" };
+};
+
+/** The message inside an API error body, whatever it was wrapped in. */
+const unwrapApiMessage = (body: string): string => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ return body;
+ }
+
+ if (typeof parsed === "string") return parsed;
+ if (typeof parsed !== "object" || parsed === null) return body;
+
+ const { error, message } = parsed as {
+ error?: unknown;
+ message?: unknown;
+ };
+
+ if (typeof error === "string") return error;
+ if (typeof message === "string") return message;
+
+ return body;
+};
+
+/**
+ * Which unique constraint a `409` hit, or `"unknown"`.
+ *
+ * The API answers a conflict with a bare `HTTPException`, whose body is the
+ * message and nothing else - `"Email already exists"` or `"Name already
+ * exists"` (`api/models/user/sign-up.ts`). Two things follow, and this function
+ * is where both are handled:
+ *
+ * 1. **The distinction is worth keeping.** They mark different fields, and the
+ * visitor's next move differs - pick another address, or pick another name.
+ * 2. **The string itself must not travel.** It is an internal message in a fixed
+ * language, so it is classified here and never forwarded; a body that matches
+ * neither becomes `"unknown"` and the caller renders its generic failure
+ * rather than printing something a backend wrote.
+ *
+ * Lives with the schema, framework-free, because both transports have to make
+ * the identical judgement: the Next.js server action reads `res.text()`, and the
+ * TanStack Start server function reads the same body off the same route. One
+ * classifier rather than two that can drift.
+ *
+ * Tolerant about *packaging* and strict about content: a body may arrive as
+ * plain text, as a JSON string, or as `{ "error": ... }` / `{ "message": ... }`
+ * (which is how VitNode's other conflict routes answer), and only the two known
+ * sentences are recognised once unwrapped.
+ */
+export const signUpConflictReason = (
+ body: string,
+): "email_exists" | "name_exists" | "unknown" => {
+ const text = unwrapApiMessage(body).trim().toLowerCase();
+
+ if (text === "email already exists") return "email_exists";
+ if (text === "name already exists") return "name_exists";
+
+ return "unknown";
+};
diff --git a/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx
new file mode 100644
index 000000000..e05dd5e84
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import type { z } from "zod";
+
+import { useTranslations } from "use-intl";
+
+import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
+
+import {
+ AutoForm,
+ type ItemAutoFormComponentProps,
+} from "@/components/form/auto-form";
+import { AutoFormCheckbox } from "@/components/form/fields/checkbox";
+import { AutoFormInput } from "@/components/form/fields/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import { removeSpecialCharacters } from "@/lib/special-characters";
+
+import type { AuthLinkComponent } from "../../auth-link";
+
+import { PasswordInput } from "../components/password-input";
+import { type SignUpSubmit, useSignUpForm } from "./use-sign-up-form";
+
+export type { SignUpSubmit };
+
+/**
+ * The registration fields, their validation and their failure states - shared.
+ *
+ * Everything that used to be Next-only here has become a prop. The form no
+ * longer imports a server action or `@/lib/navigation`: it is handed
+ * {@link SignUpSubmit} 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 live user-code preview under the username, the
+ * password checklist tooltip, the captcha widget, and the newsletter checkbox
+ * that only appears on a deployment with an email adapter.
+ */
+export const SignUpFormContent = ({
+ captcha,
+ isEmail,
+ LinkComponent,
+ onSignUp,
+ termsHref = "/terms",
+}: {
+ captcha: z.infer["captcha"];
+ /**
+ * Whether this deployment has an email adapter. It decides two things at once:
+ * whether the newsletter checkbox is offered, and - on the API's side - whether
+ * a new account starts verified or waits on a confirmation link.
+ */
+ isEmail: boolean;
+ LinkComponent: AuthLinkComponent;
+ onSignUp: SignUpSubmit;
+ termsHref?: string;
+}) => {
+ const t = useTranslations("core.auth.sign_up");
+ const { formSchema, onSubmit } = useSignUpForm({ onSignUp });
+
+ return (
+ {
+ const value: string = field.value ?? "";
+
+ return (
+
+
+ {value.length >= 3 && (
+
+ {t.rich("username.your_user_code", {
+ code: () => (
+
+ {removeSpecialCharacters(value)}
+
+ ),
+ })}
+
+ )}
+
+ );
+ },
+ },
+ {
+ id: "email",
+ component: props => (
+
+ ),
+ },
+ {
+ id: "password",
+ component: props => (
+
+ ),
+ },
+ {
+ id: "terms",
+ component: props => (
+ (
+
+ {text}
+
+ ),
+ })}
+ label={t("terms.label")}
+ />
+ ),
+ },
+ ...(isEmail
+ ? [
+ {
+ id: "newsletter" as const,
+ component: (props: ItemAutoFormComponentProps) => (
+
+ ),
+ },
+ ]
+ : []),
+ ]}
+ formSchema={formSchema}
+ mode="all"
+ onSubmit={onSubmit}
+ submitButtonProps={{
+ className: "w-full",
+ children: t("submit"),
+ }}
+ />
+ );
+};
+
+/** The form's shape while the deployment configuration is still in flight. */
+export const SignUpFormSkeleton = () => (
+
+ {[0, 1, 2].map(field => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+);
diff --git a/packages/vitnode/src/views/auth/sign-up/form/use-form.ts b/packages/vitnode/src/views/auth/sign-up/form/use-form.ts
deleted file mode 100644
index 5566034ed..000000000
--- a/packages/vitnode/src/views/auth/sign-up/form/use-form.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useTranslations } from "next-intl";
-import { toast } from "sonner";
-import { z } from "zod";
-
-import type { AutoFormOnSubmit } from "@/components/form/auto-form";
-
-import { useWrapperSignUp } from "../wrapper";
-import { mutationApi } from "./mutation-api.server";
-
-export const usePasswordZodSchema = () => {
- const t = useTranslations("core.auth.sign_up");
- const tError = useTranslations("core.global.errors");
- const invalidPassword = t("password.invalid");
-
- return z
- .string({
- message: tError("field_required"),
- })
- .regex(/^.{8,}$/, invalidPassword)
- .regex(/[A-Z]/, invalidPassword)
- .regex(/\d/, invalidPassword)
- .regex(/\W|_/, invalidPassword)
- .default("");
-};
-
-export const useFormSignUp = () => {
- const t = useTranslations("core.auth.sign_up");
- const tError = useTranslations("core.global.errors");
- const passwordSchema = usePasswordZodSchema();
-
- const formSchema = z.object({
- name: z
- .string({
- message: tError("field_required"),
- })
- .min(3, t("username.min_length"))
- .max(32, t("username.max_length"))
- .default(""),
- // .refine(value => nameRegex.test(value), t('name.invalid'))
- email: z.email({
- message: t("email.invalid"),
- }),
- password: passwordSchema,
- terms: z
- .boolean()
- .refine(value => value, t("terms.required"))
- .default(false),
- newsletter: z.boolean().default(false).optional(),
- });
-
- const { setSendingEmail } = useWrapperSignUp();
-
- const onSubmit: AutoFormOnSubmit = async (
- values,
- form,
- { captchaToken },
- ) => {
- const mutation = await mutationApi({ ...values, captchaToken });
- if (mutation.data) {
- if (!mutation.data.emailVerified) {
- setSendingEmail(mutation.data.email);
- }
-
- return;
- }
-
- const errorMessages = {
- "Email already exists": {
- field: "email",
- message: t("email.exists"),
- },
- "Name already exists": {
- field: "name",
- message: t("username.exists"),
- },
- } as const;
-
- const errorConfig =
- errorMessages[mutation.error as unknown as keyof typeof errorMessages];
-
- if (errorConfig) {
- form.setError(
- errorConfig.field,
- {
- type: "manual",
- message: errorConfig.message,
- },
- {
- shouldFocus: true,
- },
- );
-
- return;
- }
-
- toast.error(tError("title"), {
- description: tError("internal_server_error"),
- });
- };
-
- return { onSubmit, formSchema };
-};
diff --git a/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts b/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts
new file mode 100644
index 000000000..9f5529c82
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts
@@ -0,0 +1,109 @@
+"use client";
+
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { AutoFormOnSubmit } from "@/components/form/auto-form";
+
+import type {
+ SignUpFormSchema,
+ SignUpFormValues,
+ SignUpMutationResult,
+ SignUpSubmitValues,
+} from "./schema";
+
+import { useWrapperSignUp } from "../wrapper";
+import { createSignUpFormSchema, signUpFormOutcome } from "./schema";
+
+export type { SignUpSubmitValues };
+
+/**
+ * How the form asks for an account.
+ *
+ * The whole of the framework boundary for registering, and deliberately one
+ * function: it takes the field values and answers what happened, or nothing at
+ * all. What it does on success - copy a session cookie, refresh a cached
+ * session, navigate - is entirely the caller's business, which is why nothing
+ * here handles it. Next.js redirects from a server action; TanStack Start calls
+ * a server function, refreshes the canonical session query and moves the router.
+ */
+export type SignUpSubmit = (
+ values: SignUpSubmitValues,
+) => Promise;
+
+/**
+ * The registration form's behaviour, with no idea which framework is rendering
+ * it.
+ *
+ * `use-intl` rather than `next-intl` for the strings - the same module record
+ * either way - 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.
+ *
+ * ## Where the confirmation screen comes from
+ *
+ * `useWrapperSignUp` - the context {@link WrapperSignUp} mounts, which
+ * {@link SignUpContent} renders for both frameworks. When the account was
+ * created but not verified, this hands it the address and the wrapper swaps the
+ * card for the "check your email" view. Nothing about that is Next-specific,
+ * which is why it stayed a context rather than becoming a fifth prop: the form
+ * is several levels below the component that has to change shape.
+ */
+export const useSignUpForm = ({ onSignUp }: { onSignUp: SignUpSubmit }) => {
+ const t = useTranslations("core.auth.sign_up");
+ const tErrors = useTranslations("core.global.errors");
+ const { setSendingEmail } = useWrapperSignUp();
+
+ const formSchema = createSignUpFormSchema({
+ fieldRequired: tErrors("field_required"),
+ invalidEmail: t("email.invalid"),
+ invalidPassword: t("password.invalid"),
+ nameMaxLength: t("username.max_length"),
+ nameMinLength: t("username.min_length"),
+ termsRequired: t("terms.required"),
+ });
+
+ const onSubmit: AutoFormOnSubmit = async (
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ { terms: _terms, ...values }: SignUpFormValues,
+ form,
+ { captchaToken },
+ ) => {
+ const outcome = signUpFormOutcome(
+ await onSignUp({ ...values, captchaToken }),
+ );
+
+ if (!outcome) return;
+
+ if (outcome.kind === "confirmation") {
+ setSendingEmail(outcome.email);
+
+ return;
+ }
+
+ if (outcome.kind === "field") {
+ form.setError(
+ outcome.field,
+ {
+ type: "manual",
+ message:
+ outcome.field === "email"
+ ? t("email.exists")
+ : t("username.exists"),
+ },
+ { shouldFocus: true },
+ );
+
+ return;
+ }
+
+ toast.error(tErrors("title"), {
+ description: tErrors("internal_server_error"),
+ });
+ };
+
+ return { formSchema, onSubmit };
+};
diff --git a/packages/vitnode/src/views/auth/sign-up/sign-up-card.tsx b/packages/vitnode/src/views/auth/sign-up/sign-up-card.tsx
new file mode 100644
index 000000000..3bcae5b60
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/sign-up-card.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { NextAuthLink } from "../next-link";
+import { SignUpContent } from "./sign-up-content";
+
+/**
+ * {@link SignUpContent}, wired to Next.js.
+ *
+ * A client component with two slots, and that shape is load bearing - the same
+ * arrangement `SignInCard` uses. The card itself has to be one: it reads its
+ * strings from the client context `I18nProvider` mounts, it owns the
+ * confirmation state through `WrapperSignUp`, and a component type such as
+ * `LinkComponent` cannot cross the server/client boundary as a prop.
+ *
+ * `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 `SignUpView`.
+ */
+export const SignUpCard = ({
+ form,
+ sso,
+}: {
+ form: React.ReactNode;
+ sso?: React.ReactNode;
+}) => ;
diff --git a/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx b/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx
new file mode 100644
index 000000000..3338d9366
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx
@@ -0,0 +1,82 @@
+"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";
+import { WrapperSignUp } from "./wrapper";
+
+/**
+ * The registration card - the heading, the copy, and the two slots that fill it.
+ *
+ * The counterpart of `SignInContent`, and framework-free for the same reason: 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
+ * ``; a TanStack Start route has the same data from its loader before
+ * this renders at all, and passes the finished elements.
+ *
+ * ## Why the wrapper is inside
+ *
+ * {@link WrapperSignUp} is here rather than left to each caller because the
+ * "check your email" screen *replaces this card*, and a caller that forgot to
+ * mount it would get a form that succeeds and then appears to do nothing. It is
+ * ordinary client React - `useState` and a context - so both frameworks mount
+ * the same one, and the confirmation state lives exactly one level above the
+ * thing it hides.
+ */
+export const SignUpContent = ({
+ form,
+ LinkComponent,
+ signInHref = AUTH_HREF.signIn,
+ sso,
+}: {
+ form: React.ReactNode;
+ LinkComponent: AuthLinkComponent;
+ signInHref?: string;
+ sso?: React.ReactNode;
+}) => {
+ const t = useTranslations("core.auth.sign_up");
+ const tGlobal = useTranslations("core.global");
+
+ return (
+
+
+
+
+
+
+ {tGlobal("register")}
+
+ {t("desc")}
+
+
+ {form}
+
+ {sso}
+
+
+
+ {t.rich("already_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 afff4b49d..a1c93e266 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
@@ -1,80 +1,43 @@
-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 { FormSignUp } from "./form/form";
-import { WrapperSignUp } from "./wrapper";
+import { SignUpFormSkeleton } from "./form/sign-up-form-content";
+import { SignUpCard } from "./sign-up-card";
const SignUpForm = async () => {
- const { isEmail, captcha } = await getMiddlewareApi();
+ const { captcha, isEmail } = await getMiddlewareApi();
return ;
};
-const SignUpFormSkeleton = () => (
-
- {[0, 1, 2].map(field => (
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
+/**
+ * The registration page for Next.js.
+ *
+ * Everything visible is `SignUpContent`, 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, whether an email adapter
+ * exists, and the public captcha key. 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 SignUpView = () => (
+
+ }>
+
+
+ }
+ sso={
+ }>
+
+
+ }
+ />
+
);
-
-export const SignUpView = async () => {
- const [t, tGlobal] = await Promise.all([
- getTranslations("core.auth.sign_up"),
- getTranslations("core.global"),
- ]);
-
- return (
-
-
-
-
-
-
-
- {tGlobal("register")}
-
- {t("desc")}
-
-
- }>
-
-
-
- }>
-
-
-
-
-
- {t.rich("already_have_account", {
- link: text => (
-
- {text}
-
- ),
- })}
-
-
-
-
-
- );
-};
diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx
new file mode 100644
index 000000000..e24e66b34
--- /dev/null
+++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx
@@ -0,0 +1,42 @@
+import type { AuthLinkComponent } from "../auth/auth-link";
+
+import { BreadcrumbRenderContent } from "./breadcrumb-render-content";
+import { resolveMainBreadcrumb } from "./resolve-main-breadcrumb";
+
+export interface BreadcrumbMainContentProps {
+ labels?: Record;
+ LinkComponent: AuthLinkComponent;
+ overrideLastLabel?: string;
+ segments: string[];
+}
+
+/**
+ * The public site's breadcrumb, framework-free.
+ *
+ * The same two steps `BreadcrumbMain` has always taken - path segments into
+ * crumbs, crumbs into markup - with the link handed in rather than imported. The
+ * container is here rather than at each call site so both frameworks get the
+ * same spacing: Next.js renders this into the `@breadcrumb` parallel slot,
+ * TanStack Start into the shell's breadcrumb area through
+ * `staticData.breadcrumb`.
+ */
+export const BreadcrumbMainContent = ({
+ labels,
+ LinkComponent,
+ overrideLastLabel,
+ segments,
+}: BreadcrumbMainContentProps) => {
+ const crumbs = resolveMainBreadcrumb(segments, labels);
+
+ if (crumbs.length === 0) return null;
+
+ if (overrideLastLabel) {
+ crumbs[crumbs.length - 1].label = overrideLastLabel;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx
index a58eb2c78..b43b3c390 100644
--- a/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx
+++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx
@@ -1,28 +1,15 @@
-import { BreadcrumbRender } from "./breadcrumb-render";
-import { resolveMainBreadcrumb } from "./resolve-main-breadcrumb";
+import { Link } from "@/lib/navigation";
-export interface BreadcrumbMainProps {
- labels?: Record;
- overrideLastLabel?: string;
- segments: string[];
-}
+import type { BreadcrumbMainContentProps } from "./breadcrumb-main-content";
-export const BreadcrumbMain = ({
- segments,
- labels,
- overrideLastLabel,
-}: BreadcrumbMainProps) => {
- const crumbs = resolveMainBreadcrumb(segments, labels);
+import { BreadcrumbMainContent } from "./breadcrumb-main-content";
- if (crumbs.length === 0) return null;
+export type BreadcrumbMainProps = Omit<
+ BreadcrumbMainContentProps,
+ "LinkComponent"
+>;
- if (overrideLastLabel) {
- crumbs[crumbs.length - 1].label = overrideLastLabel;
- }
-
- return (
-
-
-
- );
-};
+/** {@link BreadcrumbMainContent}, wired to `next-intl`'s locale-aware `Link`. */
+export const BreadcrumbMain = (props: BreadcrumbMainProps) => (
+
+);
diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx
new file mode 100644
index 000000000..5ab751267
--- /dev/null
+++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx
@@ -0,0 +1,78 @@
+import { Fragment } from "react";
+
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import { cn } from "@/lib/utils";
+
+import type { AuthLinkComponent } from "../auth/auth-link";
+import type { BreadcrumbCrumb } from "./crumb";
+
+/**
+ * A breadcrumb trail, with the one thing it cannot decide for itself handed in.
+ *
+ * Turning `/settings` into a navigation is the only framework-specific part of a
+ * breadcrumb: Next.js wants `next-intl`'s locale-aware `Link`
+ * (`@/lib/navigation`), TanStack Start wants the router's own. Both are a
+ * component taking an anchor's props, so this takes one and stops caring - and
+ * importing neither is what lets a TanStack Start route render the same trail
+ * the Next.js `@breadcrumb` slot renders.
+ *
+ * `AuthLinkComponent` is reused rather than redeclared: it is already "every prop
+ * of an anchor, plus a required `href`", which is exactly what a crumb needs and
+ * what `MigrationLink` in `apps/web` already satisfies.
+ *
+ * Deliberately not a client component. It renders no hooks, and Next.js passes
+ * `LinkComponent` into it from a Server Component - a boundary here would turn
+ * that prop into something that cannot cross it.
+ */
+export const BreadcrumbRenderContent = ({
+ crumbs,
+ LinkComponent,
+ scrollable,
+}: {
+ crumbs: BreadcrumbCrumb[];
+ LinkComponent: AuthLinkComponent;
+ scrollable?: boolean;
+}) => {
+ if (crumbs.length === 0) return null;
+
+ return (
+
+
+ {crumbs.map((crumb, index) => (
+
+ {index > 0 && }
+
+ {crumb.isCurrent ? (
+ {crumb.label}
+ ) : crumb.isLink ? (
+
+ {crumb.label}
+
+ }
+ />
+ ) : (
+ {crumb.label}
+ )}
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx
index 061265eb3..44ab99cdd 100644
--- a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx
+++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx
@@ -3,56 +3,35 @@ import { Fragment } from "react";
import {
Breadcrumb,
BreadcrumbItem,
- BreadcrumbLink,
BreadcrumbList,
- BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "@/lib/navigation";
-import { cn } from "@/lib/utils";
import type { BreadcrumbCrumb } from "./crumb";
+import { BreadcrumbRenderContent } from "./breadcrumb-render-content";
+
+/**
+ * {@link BreadcrumbRenderContent}, wired to Next.js.
+ *
+ * Where `next-intl`'s locale-aware `Link` enters a breadcrumb, and the only
+ * place it does - the AdminCP trail and the public one both render through here.
+ */
export const BreadcrumbRender = ({
crumbs,
scrollable,
}: {
crumbs: BreadcrumbCrumb[];
scrollable?: boolean;
-}) => {
- if (crumbs.length === 0) return null;
-
- return (
-
-
- {crumbs.map((crumb, index) => (
-
- {index > 0 && }
-
- {crumb.isCurrent ? (
- {crumb.label}
- ) : crumb.isLink ? (
- {crumb.label}}
- />
- ) : (
- {crumb.label}
- )}
-
-
- ))}
-
-
- );
-};
+}) => (
+
+);
export const BreadcrumbSkeleton = ({ crumbs = 2 }: { crumbs?: number }) => (
diff --git a/packages/vitnode/src/views/files/my-files-query.test.ts b/packages/vitnode/src/views/files/my-files-query.test.ts
index 51020a4d3..eaa504cde 100644
--- a/packages/vitnode/src/views/files/my-files-query.test.ts
+++ b/packages/vitnode/src/views/files/my-files-query.test.ts
@@ -1,3 +1,4 @@
+import { hashKey } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { BulkDeleteFilesResult } from "@/lib/files/bulk-delete";
@@ -12,8 +13,8 @@ import {
describeMyFilesParams,
isMyFilesRequestError,
MY_FILES_MAX_PAGE_SIZE,
- MY_FILES_QUERY_ROOT,
myFilesQueryKey,
+ myFilesQueryRoot,
myFilesRequest,
MyFilesRequestError,
normalizeMyFilesParams,
@@ -167,20 +168,22 @@ describe("myFilesRequest", () => {
});
describe("myFilesQueryKey", () => {
- it("hangs off the root an invalidation can name", () => {
- expect(myFilesQueryKey(normalizeMyFilesParams()).slice(0, 2)).toEqual([
- ...MY_FILES_QUERY_ROOT,
- ]);
+ const keyFor = (
+ userId: number,
+ raw?: Parameters[0],
+ ) => myFilesQueryKey({ params: normalizeMyFilesParams(raw), userId });
+
+ it("hangs off the owner's own root, which an invalidation can name", () => {
+ expect(keyFor(10).slice(0, 3)).toEqual([...myFilesQueryRoot(10)]);
+ expect(myFilesQueryRoot(10)).toEqual(["files", "user", 10]);
});
it("is the same key for two spellings of the same request", () => {
- expect(myFilesQueryKey(normalizeMyFilesParams({ search: "" }))).toEqual(
- myFilesQueryKey(normalizeMyFilesParams({ first: "10" })),
- );
+ expect(keyFor(10, { search: "" })).toEqual(keyFor(10, { first: "10" }));
});
it("is a different key for everything that changes the rows", () => {
- const base = myFilesQueryKey(normalizeMyFilesParams());
+ const base = keyFor(10);
const differing = [
{ first: "40" },
{ cursor: "abc" },
@@ -191,16 +194,64 @@ describe("myFilesQueryKey", () => {
];
for (const raw of differing) {
- expect(myFilesQueryKey(normalizeMyFilesParams(raw))).not.toEqual(base);
+ expect(keyFor(10, raw)).not.toEqual(base);
}
});
+ /**
+ * The privacy invariant, as the key contract rather than as a browser test.
+ *
+ * The browser's `QueryClient` is created once per document and outlives a
+ * sign-out, so one document can hold two visitors. Under the
+ * `["files", "me", params]` this replaces, B's loader asked for the entry A
+ * had already filled - and with `refetchOnMount` and `refetchOnWindowFocus`
+ * both off, nothing refetched it. No request was made, so Hono never saw the
+ * read it would have refused, and B was shown A's file names.
+ */
+ it("gives two visitors two keys for identical parameters", () => {
+ const params = normalizeMyFilesParams({ first: "10" });
+
+ expect(myFilesQueryKey({ params, userId: 10 })).not.toEqual(
+ myFilesQueryKey({ params, userId: 20 }),
+ );
+ expect(hashKey(myFilesQueryKey({ params, userId: 10 }))).not.toBe(
+ hashKey(myFilesQueryKey({ params, userId: 20 })),
+ );
+ });
+
+ it("keeps one visitor's pages, sorts and searches under one root", () => {
+ // What a delete invalidates: the family, so every page and sort of *this*
+ // visitor's files goes stale rather than only the one on screen.
+ const root = myFilesQueryRoot(10);
+
+ for (const raw of [
+ { cursor: "abc" },
+ { orderBy: "name" },
+ { search: "x" },
+ ]) {
+ expect(keyFor(10, raw).slice(0, root.length)).toEqual([...root]);
+ }
+ });
+
+ it("puts another visitor outside that root, so a delete cannot reach them", () => {
+ // Query matches by prefix, so this is the whole of "invalidate only mine".
+ const root = myFilesQueryRoot(10);
+
+ expect(keyFor(20).slice(0, root.length)).not.toEqual([...root]);
+ });
+
it("does not vary by language, because the rows do not", () => {
// Only the column headings are translated, and the renderer resolves those.
// A locale in the key would refetch an identical list on every switch.
+ expect(JSON.stringify(keyFor(10))).not.toContain("locale");
+ });
+
+ it("sends no owner to the API, which reads it from the session cookie", () => {
+ // The id addresses a cache slot. If it reached the wire it would become an
+ // access-control parameter the browser supplies.
expect(
- JSON.stringify(myFilesQueryKey(normalizeMyFilesParams())),
- ).not.toContain("locale");
+ myFilesRequest(normalizeMyFilesParams()).args.query,
+ ).not.toHaveProperty("userId");
});
});
diff --git a/packages/vitnode/src/views/files/my-files-query.ts b/packages/vitnode/src/views/files/my-files-query.ts
index 817f1fd78..88ad2cd5e 100644
--- a/packages/vitnode/src/views/files/my-files-query.ts
+++ b/packages/vitnode/src/views/files/my-files-query.ts
@@ -303,19 +303,45 @@ export const fetchMyFilesPageInBrowser: MyFilesPageFetcher = async params => {
};
/**
- * The root every cache entry for this list hangs off.
+ * The root every cache entry for one visitor's files hangs off.
*
- * Exported so an invalidation can name the whole family - one delete makes every
- * page, sort and search of the visitor's own files stale, not just the one they
- * are looking at. TanStack Query matches keys by prefix, so this invalidates
- * exactly those and nothing else.
+ * A factory over the owner's id rather than the constant `["files", "me"]` it
+ * replaces, and the difference is a privacy one rather than a tidiness one.
+ *
+ * ## Why `"me"` was unsafe
+ *
+ * `"me"` is only stable for as long as "me" is. The browser's `QueryClient` is
+ * created once per document and outlives a sign-out, so one browser can hold two
+ * visitors in one session:
+ *
+ * A signs in -> /files -> ["files","me",params] holds A's file names
+ * A signs out
+ * B signs in -> /files -> the loader asks for ["files","me",params]
+ *
+ * and that entry is already populated. With `refetchOnMount` and
+ * `refetchOnWindowFocus` both off in VitNode's client defaults, nothing would
+ * have refetched it, so B would read A's private data with no API request made
+ * at all - which is exactly why Hono cannot defend against it. There is no
+ * request for it to authorize.
+ *
+ * Keyed by owner the two visitors address different entries, B's is empty, the
+ * fetch happens, and the API answers it from B's own session cookie.
+ *
+ * ## The id is a cache address, never a claim
+ *
+ * Nothing about this reaches the network. {@link myFilesRequest} takes no owner
+ * and `GET /users/files` derives it from the session cookie, exactly as before -
+ * so a tampered id partitions a cache differently and authorizes nothing. Were
+ * it ever sent, this would stop being a cache key and become an access-control
+ * parameter, which is the one thing it must not be.
*/
-export const MY_FILES_QUERY_ROOT = ["files", "me"] as const;
+export const myFilesQueryRoot = (userId: number) =>
+ ["files", "user", userId] as const;
/**
- * The cache entry one page of the list reads and writes.
+ * The cache entry one page of one visitor's list reads and writes.
*
- * The normalised parameters, and only those. Everything that changes which rows
+ * The owner, then the normalised parameters. Everything that changes which rows
* come back is in there - page, size, sort, search - and nothing that does not.
*
* The locale is deliberately absent. File names, folders, sizes and metadata are
@@ -327,23 +353,37 @@ export const MY_FILES_QUERY_ROOT = ["files", "me"] as const;
* An object in a key is safe - Query hashes keys structurally rather than by
* identity - which is exactly why the object has to be the *normalised* one.
*/
-export const myFilesQueryKey = (params: MyFilesParams) =>
- [...MY_FILES_QUERY_ROOT, params] as const;
+export const myFilesQueryKey = ({
+ params,
+ userId,
+}: {
+ params: MyFilesParams;
+ userId: number;
+}) => [...myFilesQueryRoot(userId), params] as const;
/**
* The visitor's files, as the one query definition every caller shares.
*
* A route loader warms it before the component renders:
*
- * context.queryClient.ensureQueryData(myFilesQueryOptions({ params }))
+ * context.queryClient.ensureQueryData(
+ * myFilesQueryOptions({ params, userId }),
+ * )
*
* and the component reads the very same options back:
*
- * const { data } = useQuery(myFilesQueryOptions({ params }))
+ * const { data } = useQuery(myFilesQueryOptions({ params, userId }))
*
* Same key, same request, same status checking - so the loader's page is the
* page the component renders, and a delete that invalidates
- * {@link MY_FILES_QUERY_ROOT} refetches through the identical contract.
+ * {@link myFilesQueryRoot} refetches through the identical contract.
+ *
+ * `userId` addresses the cache and nothing else - see {@link myFilesQueryRoot}.
+ * It is required rather than defaulted because there is no honest default: a
+ * fallback would be one shared entry again, which is the bug the parameter
+ * exists to close. Both callers take it from the one place that knows it, the
+ * `_authenticated` route context, so the loader and the component cannot drift
+ * onto two different partitions.
*
* `fetchPage` is the seam. It defaults to the browser's fetcher, which is what a
* hydrated page wants; an app that also fetches during SSR passes one that can
@@ -368,13 +408,17 @@ export const myFilesQueryKey = (params: MyFilesParams) =>
export const myFilesQueryOptions = ({
fetchPage = fetchMyFilesPageInBrowser,
params,
+ userId,
}: {
fetchPage?: MyFilesPageFetcher;
params: MyFilesParams;
+ userId: number;
}) =>
queryOptions({
+ // `userId` is deliberately absent from the request: the owner comes from
+ // the session cookie, on the server, on every call.
queryFn: async () => await fetchPage(params),
- queryKey: myFilesQueryKey(params),
+ queryKey: myFilesQueryKey({ params, userId }),
retry: false,
});
diff --git a/packages/vitnode/src/views/layouts/rate-limit-listener.tsx b/packages/vitnode/src/views/layouts/rate-limit-listener.tsx
index 8c7a755bb..4f2b7bbc3 100644
--- a/packages/vitnode/src/views/layouts/rate-limit-listener.tsx
+++ b/packages/vitnode/src/views/layouts/rate-limit-listener.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useTranslations } from "next-intl";
import React from "react";
import { toast } from "sonner";
+import { useTranslations } from "use-intl";
import {
RATE_LIMIT_EVENT,
diff --git a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
index 52b2f0d57..9622e315c 100644
--- a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
+++ b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
@@ -94,11 +94,17 @@ export type UserHeaderState =
* Where the header links to.
*
* Ordinary data, not a route table - nothing here knows or cares which
- * application currently serves a path. During the migration `/files` and
- * `/login` are rendered by TanStack Start and `/settings`, `/register`,
- * `/admin` and the profile page by Next.js, and the *link component* is what
- * decides that, per href, by asking the route tree. So a route that moves needs
- * no edit here.
+ * application currently serves a path. During the migration some of these are
+ * rendered by TanStack Start and some still by Next.js, and the *link component*
+ * is what decides which, per href, by asking the route tree. So a route that
+ * moves needs no edit here.
+ *
+ * That is a claim worth having been tested rather than asserted, and it has
+ * been: `/settings` and `/register` were Next.js pages when this record was
+ * written and are TanStack Start routes now, and the change that moved them
+ * added route files and touched neither this file nor `MigrationLink`. The
+ * AdminCP and the profile page are still the other application's.
+ * `apps/web/src/tests/header-navigation.test.ts` pins both halves.
*/
export const USER_HEADER_HREF = {
adminCp: "/admin",
`, not
+ * the settings `` the layout renders and not the `nav.devices` label the tab
+ * title is built from.
+ */
+const DevicesHeading = () => {
+ const t = useTranslations('core.auth.settings.devices')
+
+ return
+}
+
+function DevicesPending() {
+ return (
+ <>
+
+
+ >
+ )
+}
+
+function DevicesRoute() {
+ const { userId } = Route.useLoaderData()
+ const { data } = useSuspenseQuery(devicesQuery(userId))
+ const onRevoke = useRevokeDeviceCallback(userId)
+
+ return (
+ <>
+
+
+ {/*
+ The same component the Next.js page renders, handed the two things a
+ shared list cannot resolve for itself: the devices, and the revoke.
+
+ The revoke goes straight from the browser to Hono - no server function in
+ between, because it needs no server-only secret and sets no cookie - and
+ ends in an invalidation of the one `devices/me` entry, but only when the
+ list is actually wrong. A `429` or a `401` left it exactly as it was, and
+ refetching would send the same read back into whatever refused the first.
+ That rule is core's (`shouldRefreshAfterRevoke`) and is applied by
+ `#/lib/devices/devices`, so both frameworks refresh on the same condition.
+ */}
+
+ >
+ )
+}
diff --git a/apps/web/src/routes/_main/_authenticated/settings/index.tsx b/apps/web/src/routes/_main/_authenticated/settings/index.tsx
new file mode 100644
index 000000000..542914e4a
--- /dev/null
+++ b/apps/web/src/routes/_main/_authenticated/settings/index.tsx
@@ -0,0 +1,38 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { OverviewSettings } from '@vitnode/core/views/auth/settings/overview/overview'
+
+import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel'
+
+/**
+ * `/settings` - the settings root, which renders the overview panel.
+ *
+ * **Not a redirect to `/settings/overview`**, and that is a product decision
+ * rather than a shortcut. The shell shows the navigation *instead of* the panel
+ * on a narrow screen, so a visitor who opens `/settings` on a phone is looking at
+ * a menu; redirecting them straight to `/settings/overview` would skip the menu
+ * entirely and leave the mobile back link as the only way to reach it. On a
+ * desktop the two URLs look identical, which is exactly what the Next.js app does
+ * today (`routes/main/settings/page.tsx` renders `OverviewSettings` too).
+ *
+ * So `/settings` is a real page, and the navigation marks *Overview* as current
+ * on it through the `aliases` entry in `SETTINGS_NAV_ITEMS` - one rule, shared
+ * with the Next.js app, rather than a redirect and an active-state special case
+ * that could disagree.
+ *
+ * Nothing about that can loop: this route renders, it does not navigate.
+ *
+ * `staticData` is deliberately absent, so `breadcrumbOf` falls through to the
+ * layout's single "Settings" crumb - which is what the Next.js
+ * `@breadcrumb/settings/page.tsx` slot renders for this URL.
+ */
+export const Route = createFileRoute('/_main/_authenticated/settings/')({
+ component: OverviewSettings,
+ /**
+ * `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 - put `head` first and `loaderData` is `never`. Neither
+ * error names the cause.
+ */
+ loader: async ({ context }) => await loadSettingsPanel(context, 'overview'),
+ head: ({ loaderData }) => settingsPanelHead(loaderData),
+})
diff --git a/apps/web/src/routes/_main/_authenticated/settings/overview.tsx b/apps/web/src/routes/_main/_authenticated/settings/overview.tsx
new file mode 100644
index 000000000..53c1b2303
--- /dev/null
+++ b/apps/web/src/routes/_main/_authenticated/settings/overview.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { OverviewSettings } from '@vitnode/core/views/auth/settings/overview/overview'
+
+import { SettingsBreadcrumb } from '#/components/layout/settings-breadcrumb'
+import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel'
+
+/**
+ * `/settings/overview` - the overview panel at its own URL.
+ *
+ * The same component `/settings` renders, because the root is an alias of this
+ * panel rather than a redirect to it (see `settings/index.tsx`). The two routes
+ * differ in exactly one visible way, which is the breadcrumb: this one is two
+ * crumbs deep.
+ *
+ * `OverviewSettings` is the same module the Next.js page renders and is currently
+ * a heading and nothing else. Profile editing is not a feature VitNode has yet -
+ * the route name is not a specification.
+ */
+export const Route = createFileRoute('/_main/_authenticated/settings/overview')(
+ {
+ component: OverviewSettings,
+ loader: async ({ context }) => await loadSettingsPanel(context, 'overview'),
+ head: ({ loaderData }) => settingsPanelHead(loaderData),
+ staticData: { breadcrumb: },
+ },
+)
diff --git a/apps/web/src/routes/_main/_authenticated/settings/security.tsx b/apps/web/src/routes/_main/_authenticated/settings/security.tsx
new file mode 100644
index 000000000..fbcb16609
--- /dev/null
+++ b/apps/web/src/routes/_main/_authenticated/settings/security.tsx
@@ -0,0 +1,25 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { SecuritySettings } from '@vitnode/core/views/auth/settings/security/security'
+
+import { SettingsBreadcrumb } from '#/components/layout/settings-breadcrumb'
+import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel'
+
+/**
+ * `/settings/security` - the security panel.
+ *
+ * `SecuritySettings` is the same module the Next.js page renders and is currently
+ * a heading and nothing else. Password changes, two-factor enrolment, passkeys
+ * and a session log are not features VitNode has yet, and this stage migrates
+ * what exists rather than what the URL suggests might one day live here.
+ *
+ * Anonymous, this URL answers `/login?returnTo=/settings/security` from
+ * `_authenticated`'s `beforeLoad` - no check in this file, and none wanted.
+ */
+export const Route = createFileRoute('/_main/_authenticated/settings/security')(
+ {
+ component: SecuritySettings,
+ loader: async ({ context }) => await loadSettingsPanel(context, 'security'),
+ head: ({ loaderData }) => settingsPanelHead(loaderData),
+ staticData: { breadcrumb: },
+ },
+)
diff --git a/apps/web/src/routes/_main/index.tsx b/apps/web/src/routes/_main/index.tsx
index b310ab311..d63d1d735 100644
--- a/apps/web/src/routes/_main/index.tsx
+++ b/apps/web/src/routes/_main/index.tsx
@@ -13,16 +13,20 @@ import { intlQueryOptions } from '#/lib/i18n/query'
import { vitNodeShellConfig } from '#/vitnode.shell.config'
/**
- * The Stage 3 verification page, and nothing more.
+ * The locale-runtime verification page, and nothing more.
*
- * No VitNode feature route is migrated yet - `/discover`, search, auth and the
- * AdminCP all still live in the Next.js app. What this renders is the shell and
- * the locale runtime under it: the same page at `/` and at `/pl`, one route
- * file, the language taken from the URL, `` following it, the two
- * languages' messages sitting side by side in one cache, and a switcher that
- * moves between them without a reload.
+ * What it renders is the shell and the locale runtime under it: the same page
+ * at `/` and at `/pl`, one route file, the language taken from the URL,
+ * `` following it, the two languages' messages sitting side by side
+ * in one cache, and a switcher that moves between them without a reload.
*
- * It is a scaffold. Stage 4 replaces it with the real homepage.
+ * It reads only `core.global`, from the root's provider, and mounts no
+ * `RouteMessages` of its own - which is the one thing that makes it *not* a
+ * proof that i18n works. A route's own namespaces are a separate contract, and
+ * `/discover` and `/search` are the pages that exercise it. This page passing
+ * while those failed is exactly the shape the Stage 9 i18n regression took.
+ *
+ * It is a scaffold, and the real homepage replaces it when one is designed.
*/
export const Route = createFileRoute('/_main/')({
component: Home,
@@ -75,8 +79,8 @@ function Home() {
- The VitNode application shell, rendering outside Next.js. Stage 3 is - the locale runtime - no feature route has moved yet. + The VitNode application shell, rendering outside Next.js. This page is + the locale runtime on its own - the feature routes prove the rest.
@@ -103,9 +107,18 @@ function Home() { -` is not a route in this tree, and a name code is not a
+ // shape this app should start claiming by prefix.
+ expect(owns(userProfileHref('test-1'))).toBe(false)
+ })
+
+ /**
+ * Every item the menu actually renders, rather than every key the record
+ * holds - `userHeaderMenu` is what decides which of them a given visitor sees,
+ * and an item added to it without a route behind it is a link to a 404 in one
+ * application or a not-found in the other.
+ */
+ it('resolves every menu item a signed-in admin is shown', () => {
+ const items = userHeaderMenu({
+ avatarColor: '#000000',
+ isAdmin: true,
+ name: 'Test',
+ nameCode: 'test-1',
+ }).flat()
+
+ expect(items.map((item) => item.key)).toEqual([
+ 'my_profile',
+ 'files',
+ 'settings',
+ 'admin_cp',
+ ])
+
+ // Owned or not, every destination is an application-relative path with no
+ // locale in it: the prefix is `MigrationLink`'s to write, on whichever
+ // branch it takes.
+ for (const { href } of items) {
+ expect(href.startsWith('/')).toBe(true)
+ expect(href).not.toMatch(/^\/[a-z]{2}\//)
+ }
+ })
+
+ it('keeps the migrated ones owned when locale-prefixed', () => {
+ // A header rendered on `/pl` builds `/pl/settings`, and the prefix comes off
+ // before matching - otherwise reading Polish would silently move the whole
+ // user menu back onto the Next.js app.
+ for (const href of [USER_HEADER_HREF.settings, USER_HEADER_HREF.signUp]) {
+ expect(isTanStackOwnedPath(routerAt('/pl'), `/pl${href}`)).toBe(true)
+ }
+ })
+})
+
/**
* The language switcher, from the routes the header actually renders on.
*
diff --git a/apps/web/src/tests/intl-input.test.ts b/apps/web/src/tests/intl-input.test.ts
index a0c430da4..315c96e57 100644
--- a/apps/web/src/tests/intl-input.test.ts
+++ b/apps/web/src/tests/intl-input.test.ts
@@ -200,7 +200,11 @@ describe('hardening did not change what a valid request returns', () => {
expect(locale).toBe('pl')
expect(messages).toHaveProperty('core.global.close', 'Zamknij')
- expect(messages).toHaveProperty('core.global.loading', 'Loading...')
+ // `toggle_sidebar` is AdminCP copy the Polish override does not carry.
+ expect(messages).toHaveProperty(
+ 'core.global.toggle_sidebar',
+ 'Toggle Sidebar',
+ )
})
it('still ships only the namespaces that were asked for', async () => {
diff --git a/apps/web/src/tests/intl-provider.test.ts b/apps/web/src/tests/intl-provider.test.ts
index 7aae83d17..757fb9cca 100644
--- a/apps/web/src/tests/intl-provider.test.ts
+++ b/apps/web/src/tests/intl-provider.test.ts
@@ -4,15 +4,20 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const appSrc = resolve(dirname(fileURLToPath(import.meta.url)), '..')
-const root = readFileSync(join(appSrc, 'routes/__root.tsx'), 'utf8')
+const read = (path: string) => readFileSync(join(appSrc, path), 'utf8')
+
+const root = read('routes/__root.tsx')
+const routeMessages = read('components/route-messages.tsx')
/**
* The bug this file exists to prevent coming back.
*
* `@vitnode/core` is external to the Vite SSR pass, so it is loaded by Node,
- * and the `use-intl` it reaches through `next-intl` is a different module
- * record - a different React context - from the one this app's source imports.
- * Every `useTranslations` in the shared design system looks for that other one.
+ * which resolves `use-intl` to its `default` (production) build; this app's
+ * source goes through Vite's module runner, which resolves the very same
+ * package to its `development` build. Two files, two `createContext` calls, two
+ * React contexts - and every `useTranslations` in the shared design system
+ * looks for core's one.
*
* Providing only one of the two is a 500 on the first render of any core
* component, and - this is the part worth pinning - **only under `vite dev`**.
@@ -20,26 +25,52 @@ const root = readFileSync(join(appSrc, 'routes/__root.tsx'), 'utf8')
* server, the SSR tests and CI were all green while `pnpm dev` was broken.
* Nothing that runs in this suite can reproduce that, because Vitest resolves
* both through Node and gets one record. So the guard is on the source.
+ *
+ * Two places have to mount the pair, for two different scopes:
+ *
+ * __root -> core.global, above every route
+ * RouteMessages -> one route's own namespaces, over the root's
+ *
+ * A provider mounted in only one of them is the subtler half of the same bug:
+ * the shell renders in the right language and the page below it silently falls
+ * back to the root's messages, which hold none of the route's strings.
*/
-describe('the root provides every intl context core might read', () => {
+describe.each([
+ { name: '__root', source: root },
+ { name: 'RouteMessages', source: routeMessages },
+])('$name provides every intl context core might read', ({ source }) => {
it("mounts use-intl's provider, which this app's own code reads", () => {
- expect(root).toMatch(/import \{ IntlProvider \} from 'use-intl'/)
- expect(root).toContain('')
+ expect(source).toMatch(
+ /import \{ IntlProvider(?: as \w+)? \} from 'use-intl'/,
+ )
+ expect(source).toContain('')
})
- it("mounts next-intl's record too, which every core component reads", () => {
+ it("mounts core's own record too, which every shared component reads", () => {
// Deleting this line turns `pnpm dev` into a 500 and leaves every other
// check in this repository green. See the note in `__root.tsx`.
- expect(root).toMatch(
- /import \{ IntlProvider as NextIntlProvider \} from 'next-intl'/,
+ //
+ // It is imported from `@vitnode/core/lib/i18n/provider` rather than from
+ // `next-intl`: that module is loaded by whatever loaded the package, so it
+ // *is* the record core's components read, rather than one that happens to
+ // resolve the same way.
+ expect(source).toMatch(
+ /import \{ IntlProvider as CoreIntlProvider \} from '@vitnode\/core\/lib\/i18n\/provider'/,
)
- expect(root).toContain('')
+ expect(source).toContain('')
})
it('gives both the identical locale, messages and time zone', () => {
// Spread from one object rather than written twice: two providers that
// disagree would render half the page in the wrong language.
- expect(root).toMatch(/const intlProps = \{/)
- expect(root.match(/\{\.\.\.intlProps\}/g)).toHaveLength(2)
+ expect(source).toMatch(/const intlProps = \{/)
+ expect(source.match(/\{\.\.\.intlProps\}/g)).toHaveLength(2)
+ })
+
+ it('takes the locale from the router rather than from a second source', () => {
+ // `useLocale` is subscribed to the router's location, which is what makes a
+ // language switch re-render the provider - and what keeps the two providers
+ // from ever being handed different answers.
+ expect(source).toMatch(/const locale = useLocale\(\)/)
})
})
diff --git a/apps/web/src/tests/intl-query.test.ts b/apps/web/src/tests/intl-query.test.ts
index 40420a283..8a1308fd9 100644
--- a/apps/web/src/tests/intl-query.test.ts
+++ b/apps/web/src/tests/intl-query.test.ts
@@ -172,6 +172,32 @@ describe('the sets a client is holding', () => {
])
})
+ it('maps every mounted set onto the target language, and nothing else', () => {
+ // The warming step of a language switch, as the pure transform it is: the
+ // sets on screen in the current language become the same sets in the new
+ // one, read off the cache rather than from a list anybody maintains.
+ //
+ // Two sets are mounted on every page under the shell - the header's and the
+ // route's - and warming only the first is the bug this pins. The second
+ // provider would then suspend on a key nobody fetched, and a suspend caused
+ // by a store update cannot be deferred: the page blanks for a round trip.
+ const queryClient = clientHolding([
+ { locale: 'en' },
+ { locale: 'en', namespaces: [GLOBAL_NAMESPACE, 'core.search'] },
+ { locale: 'en', namespaces: ['core.auth.settings', GLOBAL_NAMESPACE] },
+ ])
+
+ const warmed = loadedIntlNamespaces(queryClient, 'en').map(
+ (namespaces) => intlQueryOptions({ locale: 'pl', namespaces }).queryKey,
+ )
+
+ expect(warmed).toEqual([
+ ['vitnode', 'intl', 'pl', GLOBAL_NAMESPACE],
+ ['vitnode', 'intl', 'pl', GLOBAL_NAMESPACE, 'core.search'],
+ ['vitnode', 'intl', 'pl', 'core.auth.settings', GLOBAL_NAMESPACE],
+ ])
+ })
+
it('falls back to the global set on an empty cache', () => {
// A switch made before anything has loaded still has to warm the one set
// every page needs.
diff --git a/apps/web/src/tests/isolation.test.ts b/apps/web/src/tests/isolation.test.ts
index 620893352..8340911ac 100644
--- a/apps/web/src/tests/isolation.test.ts
+++ b/apps/web/src/tests/isolation.test.ts
@@ -36,7 +36,22 @@ const filesUnder = (directory: string): string[] => {
}
/**
- * Every specifier a file imports.
+ * Type-only statements, which the compiler erases and no bundler ever follows.
+ *
+ * Dropped before the scan because this file walks the *runtime* graph, and the
+ * app's own source - unlike the `dist` it walks into - still has its `import
+ * type` lines in it. `lib/session.ts` names the API's users module purely so the
+ * route literals infer; following it would report Hono, Drizzle and the whole
+ * API tree as things a login screen loads.
+ */
+const withoutTypeImports = (source: string): string =>
+ source.replace(
+ /(?:^|\n)\s*(?:import|export)\s+type\s[\s\S]*?\sfrom\s*["'][^"']+["']/g,
+ '\n',
+ )
+
+/**
+ * Every specifier a file imports at runtime.
*
* Written to tolerate compiled output as well as source: a package's `dist` is
* minified onto one line, so `from"./x.js"` carries no whitespace and its
@@ -45,7 +60,7 @@ const filesUnder = (directory: string): string[] => {
*/
const importsFrom = (path: string): string[] =>
[
- ...readFileSync(path, 'utf8').matchAll(
+ ...withoutTypeImports(readFileSync(path, 'utf8')).matchAll(
/(?:^|[^\w$.])from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']|(?:^|[\n;}])\s*import\s*["']([^"']+)["']/g,
),
]
@@ -208,22 +223,20 @@ describe('the TanStack Start application stays Next-free', () => {
expect(offendersIn(webFiles(), NEXT_INTL_RUNTIME)).toEqual([])
})
- it('reaches for use-intl directly everywhere but the provider bridge', () => {
- // `next-intl` stays a dependency because `@vitnode/core`'s shared components
- // import its root entry, which is `use-intl` re-exported - and under
- // `vite dev` that is a second module record, so the root has to mount its
- // provider as well as `use-intl`'s (see `intl-provider.test.ts`). That one
- // file is the whole of the exception: no other module here may reach for
- // next-intl, and none may reach for anything but its root entry.
- // Runtime files only: `intl-provider.test.ts` asserts *about* that import,
- // so it necessarily contains the specifier the scanner is looking for.
+ it('reaches for use-intl directly, and never for next-intl', () => {
+ // There is no exception left. The root used to import `next-intl`'s
+ // `IntlProvider` to cover the second module record core's components read
+ // under `vite dev` (see `intl-provider.test.ts`); it now imports that record
+ // from the package that owns it, `@vitnode/core/lib/i18n/provider`. The two
+ // resolve to the same file today, and only one of them says why.
+ //
+ // Runtime files only: `intl-provider.test.ts` asserts *about* these imports,
+ // so it necessarily contains the specifiers the scanner is looking for.
const runtime = webFiles().filter(
(path) => !path.includes(`${sep}tests${sep}`),
)
- expect(offendersIn(runtime, ['next-intl'])).toEqual([
- 'apps/web/src/routes/__root.tsx',
- ])
+ expect(offendersIn(runtime, ['next-intl'])).toEqual([])
})
it('depends on use-intl at the same version next-intl resolves', () => {
@@ -362,9 +375,29 @@ describe('the whole graph this app imports stays Next-free', () => {
'apps/web/src/lib/auth/screens.ts',
'apps/web/src/lib/middleware-config.ts',
'apps/web/src/routes/_main/_authenticated.tsx',
- 'apps/web/src/routes/_main/_authenticated/account.tsx',
'apps/web/src/routes/login.tsx',
'apps/web/src/routes/login_.sso.$providerId.tsx',
+ // Stage 9. Registration reaches deeper still than the login card: the same
+ // `AutoForm` stack plus the captcha widget, the password checklist tooltip
+ // and the confirmation screen. Password recovery adds core's shared error
+ // screen on top. Both were Next-only until Stage 9 split their views.
+ 'apps/web/src/lib/auth/password-reset-route.ts',
+ 'apps/web/src/routes/register.tsx',
+ 'apps/web/src/routes/login_.reset-password.tsx',
+ // Stage 9. The settings subtree, which is the first *nested layout* this app
+ // renders and the first place the shared settings frame - the navigation
+ // card, the mobile back link, the panel card - is mounted outside Next.js.
+ // The devices panel is the one with data, so its graph reaches core's list,
+ // its revoke and the confirm dialog behind the revoke button.
+ 'apps/web/src/components/layout/settings-breadcrumb.tsx',
+ 'apps/web/src/lib/devices/devices.ts',
+ 'apps/web/src/lib/settings/panel.ts',
+ 'apps/web/src/routes/_main/_authenticated/settings.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/devices.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/index.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/overview.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/security.tsx',
+ 'apps/web/src/server/devices.server.ts',
// Stage 7. `/files` renders the whole data table - eight columns, the
// bulk-action bar and both confirm dialogs - which is the deepest this app
// reaches into the design system after the auth screens. That graph was
@@ -660,6 +693,86 @@ describe('the whole graph this app imports stays Next-free', () => {
expect(reached.filter((one) => one.includes('navigation'))).toEqual([])
})
})
+
+ /**
+ * Every migrated screen at once: the shared client contract is `use-intl`.
+ *
+ * The per-route blocks above ban `next-intl`'s *subpaths*, which reach Next's
+ * request scope and simply do not resolve here. This bans the root entry too,
+ * across everything this app renders, and that is a different bug it is
+ * closing.
+ *
+ * `next-intl`'s root re-exports `use-intl/react`, so a shared component that
+ * imports it *does* read the context core's provider supplies - today. It is
+ * a coincidence of how one package re-exports another, and it held only
+ * because every design-system component that reached for it happened to read
+ * `core.global`, which the root provides to every page. A component that read
+ * a route's own namespace through a second record would render the root's
+ * messages instead: no error, no missing key, just a page in the wrong
+ * language below a shell in the right one. That is the failure this asserts
+ * away, rather than trusting the re-export to keep pointing where it does.
+ *
+ * `routes/api/$` is deliberately not in the list. It mounts the Hono API,
+ * which renders emails with `createTranslator` from `next-intl`'s root - the
+ * framework-free half, on a server, in a graph that renders no React. The
+ * boundary here is about what the *browser* and the SSR pass render.
+ */
+ describe('every migrated screen takes its translations from use-intl', () => {
+ /** One entry per route file the router can render, plus the shell slots. */
+ const RENDERED = [
+ 'apps/web/src/routes/__root.tsx',
+ 'apps/web/src/routes/_main.tsx',
+ 'apps/web/src/routes/_main/index.tsx',
+ 'apps/web/src/routes/_main/discover.tsx',
+ 'apps/web/src/routes/_main/search.tsx',
+ 'apps/web/src/routes/_main/_authenticated.tsx',
+ 'apps/web/src/routes/_main/_authenticated/files.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/index.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/overview.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/devices.tsx',
+ 'apps/web/src/routes/_main/_authenticated/settings/security.tsx',
+ 'apps/web/src/routes/login.tsx',
+ 'apps/web/src/routes/login_.reset-password.tsx',
+ 'apps/web/src/routes/login_.sso.$providerId.tsx',
+ 'apps/web/src/routes/register.tsx',
+ 'apps/web/src/components/header.tsx',
+ 'apps/web/src/components/layout/main-breadcrumb.tsx',
+ 'apps/web/src/components/layout/main-header.tsx',
+ 'apps/web/src/components/layout/settings-breadcrumb.tsx',
+ 'apps/web/src/components/layout/user-header.tsx',
+ 'apps/web/src/components/route-messages.tsx',
+ ]
+
+ it('walks into the design system, where the imports it bans live', () => {
+ // Without this the assertion below would pass on a graph that stopped at
+ // the route files - which is exactly the graph that cannot break. These
+ // four are the components that reached for `next-intl` before this stage.
+ const reached = [...reachableExternals(RENDERED).visited]
+
+ for (const module of [
+ 'components/form/auto-form',
+ 'components/table/content',
+ 'components/ui/button-client',
+ 'components/confirm-action/confirm-action-alert-dialog',
+ ]) {
+ expect(
+ reached.some((path) => path.includes(module)),
+ module,
+ ).toBe(true)
+ }
+ })
+
+ it('reaches use-intl', () => {
+ expect([...reachableExternals(RENDERED).externals.keys()]).toContain(
+ 'use-intl',
+ )
+ })
+
+ it('never reaches next-intl, root entry included', () => {
+ expect(offenders(RENDERED, ['next-intl'])).toEqual([])
+ })
+ })
})
/**
diff --git a/apps/web/src/tests/locale-ssr.test.ts b/apps/web/src/tests/locale-ssr.test.ts
index d146761dc..3c8b6bb7f 100644
--- a/apps/web/src/tests/locale-ssr.test.ts
+++ b/apps/web/src/tests/locale-ssr.test.ts
@@ -60,9 +60,13 @@ describe('SSR serves one page in two languages', () => {
})
it('falls back to English for a key Polish does not translate', async () => {
+ // The rule this pins is that a language may be incomplete: `toggle_sidebar`
+ // is AdminCP copy the Polish override does not carry, and it renders in
+ // English on a page whose every other string is Polish. A translation is
+ // merged key by key over the default locale, never all-or-nothing.
const { html } = await renderPage(at('/pl'))
- expect(testId(html, 'loading')).toBe('Loading...')
+ expect(testId(html, 'fallback')).toBe('Toggle Sidebar')
})
it('gives the two URLs the same route and different public hrefs', async () => {
diff --git a/apps/web/src/tests/main-shell.test.ts b/apps/web/src/tests/main-shell.test.ts
index c7f198843..a919ecb5f 100644
--- a/apps/web/src/tests/main-shell.test.ts
+++ b/apps/web/src/tests/main-shell.test.ts
@@ -40,8 +40,15 @@ describe('the main shell is what a public page renders inside', () => {
['/', 'the front page'],
['/discover', 'the discover feed'],
['/search', 'the search page'],
- ['/account', 'a page behind the session guard'],
- ['/files', 'the files table, behind the same guard'],
+ ['/files', 'the files table, behind the session guard'],
+ // Stage 9. The settings subtree joins the shell rather than bringing a
+ // second header of its own: the layout is a child of the guard, which is a
+ // child of the shell, so a panel gets the header, the breadcrumb area, the
+ // `` landmark and the guard from where its file lives.
+ ['/settings', 'the settings root, behind the same guard'],
+ ['/settings/overview', 'a settings panel'],
+ ['/settings/devices', 'the devices panel'],
+ ['/settings/security', 'the security panel'],
['/example', "a plugin's page, mounted by area rather than by file"],
])('%s renders in the shell (%s)', (pathname) => {
expect(matchedIds(pathname)).toContain(MAIN_SHELL_ROUTE_ID)
@@ -50,12 +57,17 @@ describe('the main shell is what a public page renders inside', () => {
/**
* An auth screen is a full-height card on an otherwise empty document, and the
* header it would render has one interesting control on it: "sign in". Keeping
- * these out is what makes the shell something routes opt into - and it is the
- * shape `/register` and the password-reset screens will want when they move.
+ * these out is what makes the shell something routes opt into.
+ *
+ * Stage 9 is what makes that a policy rather than an accident of what had been
+ * migrated: registration and password recovery moved in, and they moved in
+ * *here* - outside the shell, alongside `/login` - rather than under `_main`.
*/
it.each([
['/login', 'the login screen'],
['/login/sso/google', 'the SSO callback'],
+ ['/register', 'the registration screen'],
+ ['/login/reset-password', 'the password-recovery screens'],
])('%s renders outside it (%s)', (pathname) => {
expect(matchedIds(pathname)).not.toContain(MAIN_SHELL_ROUTE_ID)
})
@@ -132,12 +144,21 @@ describe('the shell owns the main landmark', () => {
* them, a login screen with no `` is a document with no main landmark at
* all.
*/
- it.each(['login.tsx', 'login_.sso.$providerId.tsx'])(
- '%s renders exactly one of its own',
- (name) => {
- expect(landmarks(withoutComments(join(routesDir, name)))).toHaveLength(1)
- },
- )
+ it.each([
+ ['login.tsx', 1],
+ ['login_.sso.$providerId.tsx', 1],
+ // Stage 9. Registration and password recovery join the blank-auth area, so
+ // they own their landmark for the same reason.
+ ['register.tsx', 1],
+ // Two, and both correct: the page body and the route's own
+ // `notFoundComponent`, which replaces it on an install with no email
+ // adapter. They are alternatives, so a document still renders exactly one.
+ ['login_.reset-password.tsx', 2],
+ ] as const)('%s renders %i of its own', (name, count) => {
+ expect(landmarks(withoutComments(join(routesDir, name)))).toHaveLength(
+ count,
+ )
+ })
/**
* The same rule, for the pages this app does not own.
diff --git a/apps/web/src/tests/messages.test.ts b/apps/web/src/tests/messages.test.ts
index 474b126f6..00b89e603 100644
--- a/apps/web/src/tests/messages.test.ts
+++ b/apps/web/src/tests/messages.test.ts
@@ -65,15 +65,20 @@ describe('loading one language for one set of namespaces', () => {
})
it('falls back to the default locale key by key', async () => {
- // Polish translates five strings. Everything else has to keep rendering
- // English rather than degrading to `core.global.loading`.
+ // Polish translates what the migrated routes render and nothing else.
+ // `toggle_sidebar` is AdminCP copy it deliberately leaves out, and it has
+ // to keep rendering English rather than degrading to
+ // `core.global.toggle_sidebar`. A language is never all-or-nothing.
const { messages } = await loadIntlMessages({
locale: 'pl',
namespaces: ['core.global'],
})
expect(messages).toHaveProperty('core.global.save', 'Zapisz')
- expect(messages).toHaveProperty('core.global.loading', 'Loading...')
+ expect(messages).toHaveProperty(
+ 'core.global.toggle_sidebar',
+ 'Toggle Sidebar',
+ )
})
it('merges app overrides on top of what the package ships', async () => {
diff --git a/apps/web/src/tests/my-files-route.test.ts b/apps/web/src/tests/my-files-route.test.ts
index eb1691a38..9b7a533e7 100644
--- a/apps/web/src/tests/my-files-route.test.ts
+++ b/apps/web/src/tests/my-files-route.test.ts
@@ -9,7 +9,7 @@ import {
} from '@vitnode/core/components/table/url-state'
import {
MY_FILES_MAX_PAGE_SIZE,
- MY_FILES_QUERY_ROOT,
+ myFilesQueryRoot,
} from '@vitnode/core/views/files/my-files-query'
import { describe, expect, it } from 'vitest'
@@ -48,10 +48,17 @@ import { getRouter } from '#/router'
const searchFor = (query: string) =>
normalizeMyFilesRouteSearch(defaultParseSearch(query))
-/** The cache entry one URL lands in. */
-const keyFor = (query: string) =>
+/** The visitor these tests are signed in as, wherever an owner is needed. */
+const USER = 10
+
+/** Another visitor, for the entries that must never be shared with them. */
+const OTHER_USER = 20
+
+/** The cache entry one URL lands in, for one visitor. */
+const keyFor = (query: string, userId: number = USER) =>
hashKey(
- myFilesQuery({ params: myFilesRouteParams(searchFor(query)) }).queryKey,
+ myFilesQuery({ params: myFilesRouteParams(searchFor(query)), userId })
+ .queryKey,
)
describe('the route schema reads a table request out of the URL', () => {
@@ -210,12 +217,28 @@ describe('one URL, one cache entry', () => {
})
it('hangs off the root a delete invalidates', () => {
+ const root = myFilesQueryRoot(USER)
+
expect(
- myFilesQuery({ params: myFilesRouteParams({}) }).queryKey.slice(
- 0,
- MY_FILES_QUERY_ROOT.length,
- ),
- ).toEqual([...MY_FILES_QUERY_ROOT])
+ myFilesQuery({
+ params: myFilesRouteParams({}),
+ userId: USER,
+ }).queryKey.slice(0, root.length),
+ ).toEqual([...root])
+ })
+
+ /**
+ * The privacy invariant at this route's own seam.
+ *
+ * The key contract is core's and is asserted there; what is asserted here is
+ * that *this route's* query definition carries the owner through, so the entry
+ * a loader fills for one visitor cannot be the entry another visitor's loader
+ * reads. Same URL, same normalised parameters, two visitors, two entries.
+ */
+ it('gives two visitors two entries for the identical URL', () => {
+ for (const query of ['', 'orderBy=name&order=asc', 'search=logo']) {
+ expect(keyFor(query, USER)).not.toBe(keyFor(query, OTHER_USER))
+ }
})
})
@@ -356,17 +379,27 @@ describe('a delete makes the visitor’s files stale, and only those', () => {
const queryClient = new QueryClient()
const firstPage = myFilesQuery({
params: myFilesRouteParams(searchFor('')),
+ userId: USER,
})
const sorted = myFilesQuery({
params: myFilesRouteParams(searchFor('orderBy=name&order=asc')),
+ userId: USER,
+ })
+ // A partition left behind by a visitor who signed out on this browser. It is
+ // unreachable - every authenticated route builds its key from the current
+ // session - and a delete must not reach it either.
+ const otherVisitor = myFilesQuery({
+ params: myFilesRouteParams(searchFor('')),
+ userId: OTHER_USER,
})
const session = ['vitnode', 'session'] as const
queryClient.setQueryData(firstPage.queryKey, { edges: [], pageInfo: {} })
queryClient.setQueryData(sorted.queryKey, { edges: [], pageInfo: {} })
- queryClient.setQueryData(session, { user: { id: 1 } })
+ queryClient.setQueryData(otherVisitor.queryKey, { edges: [], pageInfo: {} })
+ queryClient.setQueryData(session, { user: { id: USER } })
- return { firstPage, queryClient, session, sorted }
+ return { firstPage, otherVisitor, queryClient, session, sorted }
}
const isStale = (queryClient: QueryClient, queryKey: readonly unknown[]) =>
@@ -377,18 +410,29 @@ describe('a delete makes the visitor’s files stale, and only those', () => {
// pressing a button - and reads from the cache - are wrong too.
const { firstPage, queryClient, sorted } = seed()
- void invalidateMyFiles(queryClient)
+ void invalidateMyFiles(queryClient, USER)
expect(isStale(queryClient, firstPage.queryKey)).toBe(true)
expect(isStale(queryClient, sorted.queryKey)).toBe(true)
})
+ it('leaves a previous visitor’s partition untouched', () => {
+ // Prefix matching is the whole of it: `['files','user',10]` is not a prefix
+ // of `['files','user',20,...]`, so one visitor's delete cannot refetch a
+ // list on behalf of somebody who has signed out.
+ const { otherVisitor, queryClient } = seed()
+
+ void invalidateMyFiles(queryClient, USER)
+
+ expect(isStale(queryClient, otherVisitor.queryKey)).toBe(false)
+ })
+
it('leaves everything else in the cache alone', () => {
// Emphatically not `invalidateQueries()` with no key: the session and the
// messages have not changed because a file was deleted.
const { queryClient, session } = seed()
- void invalidateMyFiles(queryClient)
+ void invalidateMyFiles(queryClient, USER)
expect(isStale(queryClient, session)).toBe(false)
})
@@ -398,7 +442,7 @@ describe('a delete makes the visitor’s files stale, and only those', () => {
// dialog that is still open.
const { firstPage, queryClient } = seed()
- void invalidateMyFiles(queryClient)
+ void invalidateMyFiles(queryClient, USER)
expect(queryClient.getQueryData(firstPage.queryKey)).toBeDefined()
})
diff --git a/apps/web/src/tests/plugin-routes.test.ts b/apps/web/src/tests/plugin-routes.test.ts
index 55566fa0b..248267e7a 100644
--- a/apps/web/src/tests/plugin-routes.test.ts
+++ b/apps/web/src/tests/plugin-routes.test.ts
@@ -398,19 +398,25 @@ 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.
+ // Stage 6. `/login` is migrated, and so are its two siblings - none of them
+ // nested under it, which is what keeps ownership a per-leaf answer.
['/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],
- // Stage 7. `/search` is a plain route; `/files` is a second page behind the
- // pathless guard, so owning it must still be decided at `/files` and not at
- // the boundary above it.
+ // Stage 9. Registration and password recovery, both outside the main shell
+ // and both non-nested siblings of `/login` - see `src/tests/auth-routes.test.ts`
+ // for why recovery in particular must not sit under it.
+ ['/register', true],
+ ['/pl/register', true],
+ ['/login/reset-password', true],
+ ['/pl/login/reset-password', true],
+ // The case owning `/login` most easily annexes by accident: a path below it
+ // that nobody has migrated. `matchRoutes` answers with `/login` and leaves
+ // the rest unconsumed - see the note below.
+ ['/login/something-else', false],
+ // Stage 7. `/search` is a plain route; `/files` is a page behind the
+ // pathless `_authenticated` guard - which adds no URL segment - so owning it
+ // must still be decided at `/files` and not at the boundary above it.
['/search', true],
['/pl/search', true],
['/files', true],
@@ -419,30 +425,47 @@ describe("the app's real route tree", () => {
// takes a pathname - so a table URL is the shape that would break if the
// query were not stripped before matching.
['/files?orderBy=name&order=asc&first=20', true],
- // Still the Next.js app's, and the case a migrated `/files` most easily
- // annexes by accident: `/settings` is a sibling of nothing here, so a
- // prefix-matching rule would answer for it. `/settings/security` is the
- // nested one - see the `/login` note below for why that distinction is
- // load-bearing rather than decorative.
- ['/settings', false],
- ['/settings/security', false],
- ['/pl/settings/security', false],
+ // Stage 9. `/settings` is a nested *layout* route with an index child, and
+ // each panel is a page two segments deep beneath it - so owning one is
+ // decided at its own path, and neither the pathless guard above nor the
+ // layout itself answers for it. `/settings` is owned because of the index
+ // child, not because the layout matched.
+ ['/settings', true],
+ ['/pl/settings', true],
+ ['/settings/overview', true],
+ ['/settings/devices', true],
+ ['/pl/settings/devices', true],
+ ['/settings/security', true],
+ ['/pl/settings/security', true],
+ // The case a migrated `/settings` most easily annexes by accident: a panel
+ // that does not exist. The layout matches `/settings` and leaves the rest
+ // unconsumed, so a prefix-matching rule would hand a page the Next.js app
+ // still serves to this router - see the `/login` note below for why that
+ // distinction is load-bearing rather than decorative.
+ ['/settings/notifications', false],
+ ['/pl/settings/notifications', false],
])('answers %s as owned: %s', (href, owned) => {
expect(isTanStackOwnedPath(getRouter(), href)).toBe(owned)
})
/**
- * Owning `/login` must not quietly annex the legacy routes beneath it.
+ * Owning `/login` must not quietly annex the paths 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.
+ * If the SSO callback or the reset-password page were *children* of `/login`,
+ * that route would match every path below it as a prefix, and `MigrationLink`
+ * would hand a page the Next.js app still serves to this router as a
+ * client-side navigation - a working page turning into a TanStack not-found.
+ * All three are therefore non-nested siblings (`login.tsx`,
+ * `login_.sso.$providerId.tsx`, `login_.reset-password.tsx`), which is what
+ * these assertions pin: exact leaves, no shared parent.
+ *
+ * `/login/something-else` is the case that still exercises it now that both
+ * real siblings are migrated - `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.
*/
- it('keeps /login an exact match, so the legacy routes under it stay legacy', () => {
+ it('keeps /login an exact match, so unmigrated paths under it stay legacy', () => {
const router = getRouter()
const deepest = (pathname: string) =>
router.matchRoutes(pathname, undefined).at(-1) as {
@@ -456,16 +479,13 @@ describe("the app's real route tree", () => {
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({
+ // A path below it that no route declares resolves to `/login` - the route id
+ // alone says "owned" here, and it is not.
+ expect(deepest('/login/something-else')).toMatchObject({
pathname: '/login',
routeId: '/login',
})
- expect(isTanStackOwnedPath(router, '/login/reset-password')).toBe(false)
+ expect(isTanStackOwnedPath(router, '/login/something-else')).toBe(false)
})
/**
diff --git a/apps/web/src/tests/recovery-contract.test.ts b/apps/web/src/tests/recovery-contract.test.ts
new file mode 100644
index 000000000..84dea09a0
--- /dev/null
+++ b/apps/web/src/tests/recovery-contract.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ changePasswordInputSchema,
+ changePasswordResultFromStatus,
+ passwordResetRequestInputSchema,
+ passwordResetRequestResultFromStatus,
+} from '#/lib/auth/contract'
+
+/**
+ * The two password-recovery mutations' decisions, without the transport.
+ *
+ * The interesting property here is not a mapping but an *absence*: there is no
+ * result the reset-request path can produce that says whether an address belongs
+ * to an account, because the API answers the same 201 either way. Several of the
+ * tests below exist to keep that true.
+ */
+
+/** What the API actually puts in the email: 32 random bytes as base64url. */
+const TOKEN = 'PSyRy0nQ0hRnfx3iCYldQ40mBLU9lqfDWtvNhrTsJI4'
+
+describe('reset-request results', () => {
+ it('reads a 201 as accepted', () => {
+ expect(passwordResetRequestResultFromStatus(201)).toEqual({ ok: true })
+ })
+
+ it('answers the same way whether or not the address exists', () => {
+ // Not a tautology: the API returns 201 for an unknown address, for a known
+ // one, and for a known one it decided not to email because a link was already
+ // requested in the last five minutes. One status, one result, nothing to
+ // enumerate.
+ expect(passwordResetRequestResultFromStatus(201)).toEqual({ ok: true })
+ })
+
+ it('has no reason that could mean "no such account"', () => {
+ const reasons = new Set(
+ [400, 429, 500, 503, 200].map((status) => {
+ const result = passwordResetRequestResultFromStatus(status)
+
+ return result.ok ? 'ok' : result.reason
+ }),
+ )
+
+ expect([...reasons].sort()).toEqual([
+ 'invalid',
+ 'rate_limited',
+ 'server_error',
+ ])
+ })
+
+ it('reads a 400 as an invalid submission - which is also a refused captcha', () => {
+ expect(passwordResetRequestResultFromStatus(400)).toEqual({
+ ok: false,
+ reason: 'invalid',
+ })
+ })
+
+ it('keeps the rate limiter apart from a server failure', () => {
+ expect(passwordResetRequestResultFromStatus(429)).toEqual({
+ ok: false,
+ reason: 'rate_limited',
+ })
+ })
+
+ it.each([200, 204, 403, 404, 500, 503])(
+ 'collapses %i into one server_error',
+ (status) => {
+ expect(passwordResetRequestResultFromStatus(status)).toEqual({
+ ok: false,
+ reason: 'server_error',
+ })
+ },
+ )
+})
+
+describe('change-password results', () => {
+ it('reads a 201 as changed', () => {
+ expect(changePasswordResultFromStatus(201)).toEqual({ ok: true })
+ })
+
+ it('reads a 400 as a link that cannot be used', () => {
+ // The API looks the row up by userId AND token AND an unexpired expiresAt, so
+ // a wrong link, a spent link and a link older than thirty minutes are one
+ // status - and "ask for a fresh one" is the answer to all three.
+ expect(changePasswordResultFromStatus(400)).toEqual({
+ ok: false,
+ reason: 'invalid_token',
+ })
+ })
+
+ it('keeps the rate limiter apart from a server failure', () => {
+ expect(changePasswordResultFromStatus(429)).toEqual({
+ ok: false,
+ reason: 'rate_limited',
+ })
+ })
+
+ it.each([200, 403, 404, 409, 500, 503])(
+ 'collapses %i into one server_error',
+ (status) => {
+ expect(changePasswordResultFromStatus(status)).toEqual({
+ ok: false,
+ reason: 'server_error',
+ })
+ },
+ )
+})
+
+describe('the reset-request input schema', () => {
+ it('lower-cases the address, as the API does before it looks one up', () => {
+ expect(
+ passwordResetRequestInputSchema.parse({
+ captchaToken: 'token',
+ email: 'Test@Test.com',
+ }).email,
+ ).toBe('test@test.com')
+ })
+
+ it('treats a missing captcha token as an empty one', () => {
+ expect(
+ passwordResetRequestInputSchema.parse({ email: 'test@test.com' })
+ .captchaToken,
+ ).toBe('')
+ })
+
+ it.each([
+ ['a value that is not an email address', { email: 'test' }],
+ ['an unbounded captcha token', { captchaToken: 'a'.repeat(8193) }],
+ ])('rejects %s', (_case, patch) => {
+ expect(
+ passwordResetRequestInputSchema.safeParse({
+ captchaToken: 'token',
+ email: 'test@test.com',
+ ...patch,
+ }).success,
+ ).toBe(false)
+ })
+})
+
+describe('the change-password input schema', () => {
+ const valid = { password: 'Test123!', token: TOKEN, userId: 123 }
+
+ it('accepts what a parsed recovery link plus a password looks like', () => {
+ expect(changePasswordInputSchema.parse(valid)).toEqual(valid)
+ })
+
+ it.each([
+ ['a userId that is still a string', { userId: '123' }],
+ ['a zero userId', { userId: 0 }],
+ ['a negative userId', { userId: -1 }],
+ ['a fractional userId', { userId: 1.5 }],
+ ['a userId past the safe integer range', { userId: 2 ** 53 }],
+ ['a token with a path separator', { token: `../${TOKEN}` }],
+ ['a token with a space', { token: `${TOKEN} x` }],
+ ['a token too short to be one', { token: 'abc' }],
+ ['an unbounded token', { token: 'a'.repeat(513) }],
+ ['a seven-character password', { password: 'Test12!' }],
+ ['an unbounded password', { password: 'a'.repeat(1025) }],
+ ])('rejects %s rather than forwarding it', (_case, patch) => {
+ // This runs on the server-function boundary, where the input is whatever a
+ // caller posted - not whatever the recovery URL contained.
+ expect(
+ changePasswordInputSchema.safeParse({ ...valid, ...patch }).success,
+ ).toBe(false)
+ })
+})
diff --git a/apps/web/src/tests/registration-contract.test.ts b/apps/web/src/tests/registration-contract.test.ts
new file mode 100644
index 000000000..07ad9a261
--- /dev/null
+++ b/apps/web/src/tests/registration-contract.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ shouldRefreshSessionAfterSignUp,
+ signUpInputSchema,
+ signUpResultFromStatus,
+} from '#/lib/auth/contract'
+
+/**
+ * The registration transport's decisions, without the transport.
+ *
+ * Every status the sign-up route can answer, and every shape its `201` body can
+ * arrive in, 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.
+ */
+
+const success = { email: 'test@test.com', emailVerified: true }
+
+describe('sign-up results', () => {
+ it('reads a 201 as an account, carrying the address and the flag', () => {
+ expect(signUpResultFromStatus(201, { body: success })).toEqual({
+ email: 'test@test.com',
+ emailVerified: true,
+ ok: true,
+ })
+ })
+
+ it('keeps an unverified account distinct from a verified one', () => {
+ expect(
+ signUpResultFromStatus(201, {
+ body: { email: 'test@test.com', emailVerified: false },
+ }),
+ ).toEqual({
+ email: 'test@test.com',
+ emailVerified: false,
+ ok: true,
+ })
+ })
+
+ it.each([
+ ['no body at all', undefined],
+ ['a body with no flag', { email: 'test@test.com' }],
+ [
+ 'a flag that is not a boolean',
+ { email: 'a@b.com', emailVerified: 'yes' },
+ ],
+ ['a body with no address', { emailVerified: true }],
+ ['a string', 'created'],
+ ['null', null],
+ ])('refuses to read %s as a session rather than guessing', (_case, body) => {
+ // `emailVerified` decides whether the visitor now holds a session cookie.
+ // A body that cannot be parsed must not read as `false` by accident, so an
+ // unreadable 201 is a server error.
+ expect(signUpResultFromStatus(201, { body })).toEqual({
+ ok: false,
+ reason: 'server_error',
+ })
+ })
+
+ it('reads a 400 as an invalid submission - which is also a refused captcha', () => {
+ // `captchaMiddleware` answers 400 for both "token is required" and
+ // "validation failed", and the API gives a caller no way to tell those from a
+ // body its schema rejected.
+ expect(signUpResultFromStatus(400)).toEqual({
+ ok: false,
+ reason: 'invalid',
+ })
+ })
+
+ it.each([
+ ['Email already exists', 'email_exists'],
+ ['Name already exists', 'name_exists'],
+ ['{"error":"Email already exists"}', 'email_exists'],
+ ])('pins a 409 saying %s to a field', (conflict, reason) => {
+ expect(signUpResultFromStatus(409, { conflict })).toEqual({
+ ok: false,
+ reason,
+ })
+ })
+
+ it.each([undefined, '', 'Something else', 'Name code already exists'])(
+ 'reads a 409 nobody could classify (%s) as a plain conflict',
+ (conflict) => {
+ expect(signUpResultFromStatus(409, { conflict })).toEqual({
+ ok: false,
+ reason: 'conflict',
+ })
+ },
+ )
+
+ it('keeps the rate limiter apart from a server failure', () => {
+ // `notifyRateLimited` - the toast the browser fetcher raises - is a no-op on
+ // a server, so a mutation behind a server function is the only place a 429
+ // can be observed at all.
+ expect(signUpResultFromStatus(429)).toEqual({
+ ok: false,
+ reason: 'rate_limited',
+ })
+ })
+
+ it.each([200, 202, 403, 404, 500, 503])(
+ 'collapses %i into one server_error',
+ (status) => {
+ expect(signUpResultFromStatus(status)).toEqual({
+ ok: false,
+ reason: 'server_error',
+ })
+ },
+ )
+
+ it('never carries the API error body into the result', () => {
+ const result = signUpResultFromStatus(409, {
+ conflict: 'Email already exists at /api/@vitnode/core/users/sign_up',
+ })
+
+ expect(JSON.stringify(result)).not.toContain('api')
+ })
+})
+
+describe('the sign-up input schema', () => {
+ const valid = {
+ captchaToken: 'token',
+ email: 'Test@Test.com',
+ name: 'tester',
+ password: 'Test123!',
+ }
+
+ it('lower-cases the address, exactly as the API does before it looks one up', () => {
+ expect(signUpInputSchema.parse(valid).email).toBe('test@test.com')
+ })
+
+ it('treats a missing captcha token as an empty one', () => {
+ // `useCaptcha` reports itself ready with no token when this deployment has no
+ // captcha configured, and the API's middleware is a no-op in that case.
+ const { captchaToken, ...rest } = valid
+
+ expect(captchaToken).toBe('token')
+ expect(signUpInputSchema.parse(rest).captchaToken).toBe('')
+ })
+
+ it.each([
+ ['a name with doubled spaces', { name: 'te ster' }],
+ ['a name with a slash', { name: 'te/ster' }],
+ ['a name with a newline', { name: 'tes\nter' }],
+ ['a two-character name', { name: 'ab' }],
+ ['a name past 32 characters', { name: 'a'.repeat(33) }],
+ ['a seven-character password', { password: 'Test12!' }],
+ ['an unbounded password', { password: 'a'.repeat(1025) }],
+ ['an unbounded captcha token', { captchaToken: 'a'.repeat(8193) }],
+ ['a value that is not an email address', { email: 'test' }],
+ ])('rejects %s', (_case, patch) => {
+ expect(signUpInputSchema.safeParse({ ...valid, ...patch }).success).toBe(
+ false,
+ )
+ })
+
+ it.each([
+ ['letters beyond ASCII', 'Zażółć gęślą'],
+ ['digits', 'tester2000'],
+ ['the punctuation the API allows', 'te.st_er-name@x'],
+ ])('accepts %s in a name, as the API does', (_case, name) => {
+ expect(signUpInputSchema.safeParse({ ...valid, name }).success).toBe(true)
+ })
+
+ it('does not accept a terms field it would forward', () => {
+ // The tick is a local precondition; the API has no field for it, so it is
+ // stripped rather than sent.
+ const parsed = signUpInputSchema.parse({ ...valid, terms: true })
+
+ expect(parsed).not.toHaveProperty('terms')
+ })
+})
+
+describe('whether registration produced a session to go and read', () => {
+ it('refreshes only for a verified account', () => {
+ // Which is exactly when the API called `createSessionByUserId` on the same
+ // request, so the 201 carried the cookie `saveApiCookies` has just written.
+ expect(shouldRefreshSessionAfterSignUp({ ...success, ok: true })).toBe(true)
+ })
+
+ it('does not pretend an unverified visitor is signed in', () => {
+ expect(
+ shouldRefreshSessionAfterSignUp({
+ email: 'test@test.com',
+ emailVerified: false,
+ ok: true,
+ }),
+ ).toBe(false)
+ })
+
+ it.each([
+ 'conflict',
+ 'email_exists',
+ 'invalid',
+ 'name_exists',
+ 'rate_limited',
+ 'server_error',
+ ] as const)('does not refresh after a %s failure', (reason) => {
+ expect(shouldRefreshSessionAfterSignUp({ ok: false, reason })).toBe(false)
+ })
+})
diff --git a/apps/web/src/tests/registration-screens.test.ts b/apps/web/src/tests/registration-screens.test.ts
new file mode 100644
index 000000000..47793837c
--- /dev/null
+++ b/apps/web/src/tests/registration-screens.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ changePasswordFormResult,
+ passwordResetFormResult,
+ signUpFormResult,
+} from '#/lib/auth/screens'
+
+/**
+ * The registration and recovery contracts translated into the vocabulary
+ * `@vitnode/core`'s shared forms speak. Total functions over finite unions, so
+ * every outcome the API can produce is checked here rather than in a browser.
+ */
+
+describe('signUpFormResult', () => {
+ it('says nothing for a verified account, which is how the form knows the caller is leaving', () => {
+ expect(
+ signUpFormResult({
+ email: 'test@test.com',
+ emailVerified: true,
+ ok: true,
+ }),
+ ).toBeUndefined()
+ })
+
+ it('asks for the confirmation screen when the account is not verified', () => {
+ // The visitor is *not* signed in here, and this is the shape that says so:
+ // the form swaps itself for "check your email" instead of standing down.
+ expect(
+ signUpFormResult({
+ email: 'test@test.com',
+ emailVerified: false,
+ ok: true,
+ }),
+ ).toEqual({ emailConfirmation: 'test@test.com' })
+ })
+
+ it.each([
+ ['email_exists', 'email_exists'],
+ ['name_exists', 'name_exists'],
+ ] as const)(
+ 'passes %s through so the right field is marked',
+ (reason, message) => {
+ expect(signUpFormResult({ ok: false, reason })).toEqual({ message })
+ },
+ )
+
+ it.each(['conflict', 'invalid', 'rate_limited', 'server_error'] as const)(
+ 'renders %s as the internal-error toast',
+ (reason) => {
+ // Deliberate collapse: a visitor cannot act on the difference between a
+ // 409 whose field we could not name, a refused captcha and a rate limit.
+ // The distinctions survive in the server log.
+ expect(signUpFormResult({ ok: false, reason })).toEqual({
+ message: 'Internal Server Error',
+ })
+ },
+ )
+
+ it('never returns a shape that both stands down and asks for the confirmation screen', () => {
+ const unverified = signUpFormResult({
+ email: 'test@test.com',
+ emailVerified: false,
+ ok: true,
+ })
+
+ expect(unverified).toBeDefined()
+ expect(unverified?.message).toBeUndefined()
+ })
+})
+
+describe('passwordResetFormResult', () => {
+ it('says nothing for an accepted request', () => {
+ expect(passwordResetFormResult({ ok: true })).toBeUndefined()
+ })
+
+ it.each(['invalid', 'rate_limited', 'server_error'] as const)(
+ 'renders %s as the internal-error toast',
+ (reason) => {
+ expect(passwordResetFormResult({ ok: false, reason })).toEqual({
+ message: 'Internal Server Error',
+ })
+ },
+ )
+})
+
+describe('changePasswordFormResult', () => {
+ it('says nothing on success - the form raises its own toast and leaves', () => {
+ expect(changePasswordFormResult({ ok: true })).toBeUndefined()
+ })
+
+ it('keeps an unusable link as itself, because the visitor can act on it', () => {
+ expect(
+ changePasswordFormResult({ ok: false, reason: 'invalid_token' }),
+ ).toEqual({ message: 'invalid_token' })
+ })
+
+ it.each(['rate_limited', 'server_error'] as const)(
+ 'renders %s as the generic failure',
+ (reason) => {
+ expect(changePasswordFormResult({ ok: false, reason })).toEqual({
+ message: 'internal_server_error',
+ })
+ },
+ )
+})
diff --git a/apps/web/src/tests/route-namespaces.test.ts b/apps/web/src/tests/route-namespaces.test.ts
new file mode 100644
index 000000000..32b5883fb
--- /dev/null
+++ b/apps/web/src/tests/route-namespaces.test.ts
@@ -0,0 +1,304 @@
+import { readFileSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+import { HEADER_NAMESPACES } from '#/components/header'
+import { passwordResetNamespaces } from '#/lib/auth/password-reset-route'
+import { SETTINGS_NAMESPACES } from '#/lib/settings/panel'
+import { loadIntlMessages } from '#/server/messages.server'
+
+const appSrc = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const read = (path: string) => readFileSync(join(appSrc, path), 'utf8')
+
+/**
+ * The route → namespace audit, as a test rather than as a document.
+ *
+ * Three separate things have to agree for one page to render in the language
+ * its URL claims, and none of them is visible from the others:
+ *
+ * the loader ensures intlQueryOptions({ locale, namespaces })
+ * the provider reads the same options back, by the same key
+ * the Polish file carries a branch for each of those namespaces
+ *
+ * The first two disagreeing is a suspend on a key nobody warmed - a page that
+ * blanks for a round trip, or on a language switch does not repaint at all. The
+ * third missing is the quieter one, and the one that produced the Stage 9
+ * report: every screen renders, `` says `pl`, the dates are Polish,
+ * and the copy is English - which looks exactly like a broken locale runtime
+ * from the outside.
+ *
+ * The namespace lists below are the audit table. They are written out rather
+ * than imported so that changing a route's set has to be a deliberate edit
+ * here too.
+ */
+
+/** Every namespace set a migrated route declares, spelled out. */
+const ROUTES = [
+ {
+ constant: 'DISCOVER_NAMESPACES',
+ file: 'routes/_main/discover.tsx',
+ namespaces: ['core.global', 'core.search'],
+ route: '/discover',
+ },
+ {
+ constant: 'SEARCH_NAMESPACES',
+ file: 'routes/_main/search.tsx',
+ namespaces: ['core.global', 'core.search'],
+ route: '/search',
+ },
+ {
+ constant: 'LOGIN_NAMESPACES',
+ file: 'routes/login.tsx',
+ namespaces: ['core.global', 'core.auth.sign_in', 'core.auth.sso'],
+ route: '/login',
+ },
+ {
+ constant: 'REGISTER_NAMESPACES',
+ file: 'routes/register.tsx',
+ namespaces: ['core.global', 'core.auth.sign_up', 'core.auth.sso'],
+ route: '/register',
+ },
+ {
+ constant: 'CALLBACK_NAMESPACES',
+ file: 'routes/login_.sso.$providerId.tsx',
+ namespaces: ['core.global', 'core.auth.sso'],
+ route: '/login/sso/$providerId',
+ },
+ {
+ constant: 'FILES_NAMESPACES',
+ file: 'routes/_main/_authenticated/files.tsx',
+ namespaces: ['core.files', 'core.global'],
+ route: '/files',
+ },
+] as const
+
+/**
+ * `const NAME = [...] as const`, read back out of the source.
+ *
+ * These are route-local by design - a route's namespaces are nobody else's
+ * business - so there is nothing to import. Parsing them is what lets this test
+ * compare the declared set against the table above without exporting a constant
+ * purely so a test can see it.
+ */
+const declaredNamespaces = (source: string, constant: string): string[] => {
+ const match = new RegExp(
+ `const ${constant} = \\[([\\s\\S]*?)\\] as const`,
+ ).exec(source)
+
+ expect(
+ match,
+ `${constant} is declared as an \`as const\` array`,
+ ).not.toBeNull()
+
+ return [...(match?.[1] ?? '').matchAll(/'([^']+)'/g)].map(
+ ([, value]) => value,
+ )
+}
+
+describe.each(ROUTES)('$route declares one namespace set', (entry) => {
+ const source = read(entry.file)
+
+ it('declares the set this audit expects', () => {
+ expect(declaredNamespaces(source, entry.constant)).toEqual([
+ ...entry.namespaces,
+ ])
+ })
+
+ it('warms it in the loader and mounts the same constant', () => {
+ // The same identifier in both places, not two lists that happen to match:
+ // the namespace list is part of the query key, so a loader that warmed a
+ // different set warmed a key nobody reads.
+ expect(source).toContain(`namespaces: ${entry.constant},`)
+ expect(source).toContain(``)
+ })
+
+ it('always includes the global namespace', () => {
+ // `RouteMessages` mounts its provider *over* the root's rather than adding
+ // to it, so a set that omitted `core.global` would take the shell's strings
+ // away from everything below it.
+ expect(entry.namespaces).toContain('core.global')
+ })
+})
+
+/**
+ * The three routes whose set is not a route-local constant.
+ *
+ * Each has a reason: the shell's is shared with the header that reads it, the
+ * settings subtree's is shared with the breadcrumb and four panels, and
+ * password recovery's depends on which half of the flow the URL is in.
+ */
+describe('the shared namespace sets', () => {
+ it('gives the header and the shell one list', () => {
+ // The shell's loader warms `headerIntlQueryOptions`, which is built from
+ // `HEADER_NAMESPACES`, which is what `Header` reads back. One export, so a
+ // loader that warmed a different set is not expressible.
+ expect([...HEADER_NAMESPACES]).toEqual(['core.global', 'core.search'])
+ expect(read('routes/_main.tsx')).toContain('headerIntlQueryOptions({')
+ expect(read('components/header.tsx')).toContain(
+ 'useSuspenseQuery(headerIntlQueryOptions({ locale }))',
+ )
+ })
+
+ it('gives the settings layout, its panels and its breadcrumb one list', () => {
+ expect([...SETTINGS_NAMESPACES]).toEqual([
+ 'core.auth.settings',
+ 'core.global',
+ ])
+
+ for (const file of [
+ 'routes/_main/_authenticated/settings.tsx',
+ 'components/layout/settings-breadcrumb.tsx',
+ ]) {
+ expect(read(file), file).toContain('SETTINGS_NAMESPACES')
+ }
+ })
+
+ it('gives password recovery a set per mode, from one function', () => {
+ // The loader warms `passwordResetNamespaces(mode)` and returns it; the
+ // component mounts what the loader returned, so the two cannot diverge.
+ expect([...passwordResetNamespaces('request')]).toEqual([
+ 'core.global',
+ 'core.auth.sign_up',
+ 'core.auth.reset_password',
+ ])
+ expect([...passwordResetNamespaces('change')]).toEqual([
+ 'core.global',
+ 'core.auth.sign_up',
+ 'core.auth.reset_password',
+ 'core.auth.change_password',
+ ])
+
+ const source = read('routes/login_.reset-password.tsx')
+
+ expect(source).toContain('const namespaces = passwordResetNamespaces(')
+ expect(source).toContain('')
+ })
+})
+
+/** Every namespace any migrated route mounts, de-duplicated. */
+const ALL_NAMESPACES = [
+ ...new Set([
+ ...ROUTES.flatMap((entry) => entry.namespaces),
+ ...HEADER_NAMESPACES,
+ ...SETTINGS_NAMESPACES,
+ ...passwordResetNamespaces('change'),
+ ]),
+].sort((a, b) => a.localeCompare(b))
+
+/**
+ * The language switcher must not know any of this.
+ *
+ * Which sets are on screen is the cache's answer, not a list. A namespace
+ * literal appearing in the locale layer means somebody hard-coded one, and the
+ * next route to declare its own would silently stop being warmed on a switch.
+ */
+describe('the locale layer names no route namespace', () => {
+ it('keeps the switcher free of namespace literals', () => {
+ const client = read('lib/i18n/client.ts')
+
+ for (const namespace of ALL_NAMESPACES.filter(
+ (one) => one !== 'core.global',
+ )) {
+ expect(client, namespace).not.toContain(namespace)
+ }
+ })
+})
+
+/**
+ * Polish coverage, at the granularity VitNode actually promises.
+ *
+ * Per *namespace*, not per key: an incomplete translation is a supported state
+ * and falls back to English key by key. What is not supported is a namespace a
+ * migrated route renders with no Polish in it at all - that is a screen that
+ * looks untranslated, which is indistinguishable from a broken runtime.
+ */
+describe('every namespace a migrated route renders has Polish', () => {
+ const translatedLeaves = (tree: unknown): number => {
+ if (typeof tree === 'string') return 1
+ if (typeof tree !== 'object' || tree === null) return 0
+
+ return Object.values(tree).reduce(
+ (total, value) => total + translatedLeaves(value),
+ 0,
+ )
+ }
+
+ const branch = (messages: unknown, namespace: string): unknown =>
+ namespace
+ .split('.')
+ .reduce(
+ (node, key) => (node as Record | undefined)?.[key],
+ messages,
+ )
+
+ it.each(ALL_NAMESPACES)('%s', async (namespace) => {
+ const { messages } = await loadIntlMessages({
+ locale: 'pl',
+ namespaces: [namespace],
+ })
+ const pl = JSON.parse(
+ readFileSync(join(appSrc, 'locales/@vitnode/core/pl.json'), 'utf8'),
+ ) as unknown
+
+ // The merged tree always has the branch - English sits underneath it. What
+ // is being asserted is that the *override* carries one too.
+ expect(translatedLeaves(branch(messages, namespace))).toBeGreaterThan(0)
+ expect(translatedLeaves(branch(pl, namespace))).toBeGreaterThan(0)
+ })
+})
+
+/**
+ * The two canaries from the regression report, in the one place the runtime
+ * can be checked without a browser.
+ *
+ * `loadIntlMessages` is the whole server half of a route's messages: it is what
+ * the loader's server function calls, and what `RouteMessages` reads back. If
+ * these strings come out Polish here and the page renders English, the fault is
+ * in the provider tree; if they come out English here, no provider could have
+ * saved it.
+ */
+describe('the /discover and /search canaries resolve in Polish', () => {
+ it.each([
+ ['discoverTitle', 'Odkrywaj'],
+ ['discoverDesc', 'Zobacz najnowszą aktywność w społeczności.'],
+ ['loadMore', 'Wczytaj więcej'],
+ ['title', 'Szukaj'],
+ ['desc', 'Przeszukaj wszystko w społeczności.'],
+ ['sortBy', 'Sortuj według'],
+ ])('core.search.%s is "%s"', async (key, expected) => {
+ const { messages } = await loadIntlMessages({
+ locale: 'pl',
+ namespaces: ['core.global', 'core.search'],
+ })
+
+ expect(messages).toHaveProperty(`core.search.${key}`, expected)
+ })
+
+ it('translates the header nav that sits above both of them', async () => {
+ // `core.search.nav.*`, read by `Header` through `createTranslator` rather
+ // than through a provider - the shell was the visible half of the report.
+ const { messages } = await loadIntlMessages({
+ locale: 'pl',
+ namespaces: [...HEADER_NAMESPACES],
+ })
+
+ expect(messages).toHaveProperty('core.search.nav.discover', 'Odkrywaj')
+ expect(messages).toHaveProperty('core.search.nav.search', 'Szukaj')
+ })
+
+ it('leaves English exactly as it was', async () => {
+ // Adding a language may not reword the default one.
+ const { messages } = await loadIntlMessages({
+ locale: 'en',
+ namespaces: ['core.global', 'core.search'],
+ })
+
+ expect(messages).toHaveProperty('core.search.discoverTitle', 'Discover')
+ expect(messages).toHaveProperty(
+ 'core.search.discoverDesc',
+ 'See the latest activity across the community.',
+ )
+ expect(messages).toHaveProperty('core.global.login', 'Login')
+ })
+})
diff --git a/apps/web/src/tests/session-query.test.ts b/apps/web/src/tests/session-query.test.ts
index de8f0b440..9b2931634 100644
--- a/apps/web/src/tests/session-query.test.ts
+++ b/apps/web/src/tests/session-query.test.ts
@@ -1,23 +1,57 @@
-import { describe, expect, it } from 'vitest'
+import { QueryClient } from '@tanstack/react-query'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { SessionApi } from '#/lib/session'
-import { sessionQueryOptions } from '#/lib/auth/query'
import { SESSION_QUERY_KEY } from '#/lib/auth/shared'
/**
- * The canonical session query's policy, as plain options.
+ * The canonical session query's policy, and the one property of it that a route
+ * guard's correctness rests on.
*
- * 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.
+ * No render, no request and no DOM: `sessionQueryOptions()` is an object, and
+ * everything below drives a `QueryClient` held in memory with the transport
+ * stubbed. What is being exercised is this app's *reading* rules - which call
+ * consults an invalidation, and which does not - because those are the rules
+ * whose being wrong is silent.
+ */
+
+/**
+ * What the stubbed session read does next, and how often it was asked.
*
- * 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.
+ * A rejection is a *value* here rather than a `vi.fn()` reconfigured per test,
+ * because the module factory below has to be self-contained - it is hoisted
+ * above every import - and one flag is less machinery than a spy that then has
+ * to be reset.
*/
+let nextSession: SessionApi = { user: null } as SessionApi
+let nextFailure: Error | null = null
+let reads = 0
+
+vi.mock('#/lib/session', () => ({
+ getSession: async () => {
+ reads += 1
+
+ if (nextFailure) return Promise.reject(nextFailure)
+
+ return Promise.resolve(nextSession)
+ },
+}))
+
+const { ensureAuthState, invalidateSession, sessionQueryOptions } =
+ await import('#/lib/auth/query')
+
+const anonymous = { user: null } as SessionApi
+const signedIn = {
+ user: { id: 42, isAdmin: false, name: 'Test' },
+} as SessionApi
+
+beforeEach(() => {
+ nextSession = anonymous
+ nextFailure = null
+ reads = 0
+})
+
describe('the canonical session query', () => {
it('asks once and lets the failure surface', () => {
expect(sessionQueryOptions().retry).toBe(false)
@@ -27,3 +61,98 @@ describe('the canonical session query', () => {
expect(sessionQueryOptions().queryKey).toEqual(SESSION_QUERY_KEY)
})
})
+
+/**
+ * What a guard sees after a sign-in, which is the whole of this suite.
+ *
+ * The bug these pin is not hypothetical - it was live until Stage 9's review.
+ * `ensureAuthState` read through `ensureQueryData`, which returns cached data
+ * the moment any exists and consults neither staleness nor invalidation:
+ *
+ * if (cachedData !== undefined) return Promise.resolve(cachedData)
+ *
+ * so `invalidateSession()` did not, on its own, make the next guard re-read. It
+ * worked only because `invalidateQueries` ends in
+ * `refetchQueries({ type: 'active' })` and `RealtimeListeners` happens to mount
+ * an observer of that entry at the root - a component that exists for the
+ * WebSocket's sake. Every one of these tests runs with **no observers at all**,
+ * which is what makes them a test of the guard rather than of that accident.
+ */
+describe('a guard reads the session again once it has been invalidated', () => {
+ it('reads once when nothing is cached', async () => {
+ const queryClient = new QueryClient()
+
+ await ensureAuthState(queryClient)
+
+ expect(reads).toBe(1)
+ })
+
+ it('does not read again inside the stale window', async () => {
+ // The preload property `SESSION_STALE_TIME` exists for: the router runs
+ // `defaultPreload: 'intent'`, so hovering a guarded link runs its
+ // `beforeLoad`, and that must not cost a round trip per hover.
+ const queryClient = new QueryClient()
+
+ await ensureAuthState(queryClient)
+ await ensureAuthState(queryClient)
+ await ensureAuthState(queryClient)
+
+ expect(reads).toBe(1)
+ })
+
+ it('reads again after an invalidation, with nothing observing the entry', async () => {
+ const queryClient = new QueryClient()
+
+ await ensureAuthState(queryClient)
+ await invalidateSession(queryClient)
+ await ensureAuthState(queryClient)
+
+ expect(reads).toBe(2)
+ })
+
+ it('answers with the new visitor rather than the cached one', async () => {
+ // The sign-in flow, in the order `useSignInAction` performs it: the API has
+ // set the cookie, the entry is invalidated, and only then does the router
+ // move. A guard at the destination must decide on the new session.
+ const queryClient = new QueryClient()
+
+ const before = await ensureAuthState(queryClient)
+ expect(before.isAuthenticated).toBe(false)
+
+ nextSession = signedIn
+ await invalidateSession(queryClient)
+
+ const after = await ensureAuthState(queryClient)
+ expect(after.isAuthenticated).toBe(true)
+ expect(after.user?.id).toBe(42)
+ })
+
+ it('would not have, through ensureQueryData', async () => {
+ // The control, and the reason this suite exists. Without it every assertion
+ // above would pass on the implementation that had the bug - `ensureAuthState`
+ // could go back to `ensureQueryData` and only this fails.
+ const queryClient = new QueryClient()
+
+ await queryClient.ensureQueryData(sessionQueryOptions())
+ nextSession = signedIn
+ await invalidateSession(queryClient)
+
+ const stale = await queryClient.ensureQueryData(sessionQueryOptions())
+
+ expect(reads).toBe(1)
+ expect(stale.user).toBeNull()
+ })
+
+ it('rejects rather than answering when the session cannot be read', async () => {
+ // `fetchQuery` propagates, where `prefetchQuery` swallows. A guard must not
+ // be handed a stale answer during an outage - `_authenticated` leaves the
+ // rejection to the router's error path rather than signing anybody out.
+ const queryClient = new QueryClient()
+
+ nextFailure = new Error('the session could not be read')
+
+ await expect(ensureAuthState(queryClient)).rejects.toThrow(
+ 'the session could not be read',
+ )
+ })
+})
diff --git a/apps/web/src/tests/settings-routes.test.ts b/apps/web/src/tests/settings-routes.test.ts
new file mode 100644
index 000000000..1dc3fc1b0
--- /dev/null
+++ b/apps/web/src/tests/settings-routes.test.ts
@@ -0,0 +1,312 @@
+import {
+ activeSettingsNavKey,
+ isSettingsNavItemActive,
+ isSettingsRootPath,
+ SETTINGS_NAV_ITEMS,
+ SETTINGS_ROOT_HREF,
+ settingsNavHref,
+} from '@vitnode/core/views/auth/settings/settings-nav'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+import type { BreadcrumbMatch } from '#/lib/breadcrumb'
+
+import { breadcrumbOf } from '#/lib/breadcrumb'
+import { isTanStackOwnedPath } from '#/lib/migration-navigation'
+import { getRouter } from '#/router'
+
+import { withoutComments } from './source'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const settingsDir = resolve(here, '../routes/_main/_authenticated/settings')
+const layoutRoute = resolve(here, '../routes/_main/_authenticated/settings.tsx')
+
+/**
+ * The settings navigation, as data.
+ *
+ * Shared by both frameworks (`packages/vitnode/src/views/auth/settings/
+ * settings-nav.ts`), which is the reason it is worth pinning here rather than
+ * only in the package: this app's route tree has to offer exactly the panels the
+ * menu lists, and a menu entry with no route behind it is a link to a 404.
+ */
+describe('the settings navigation model', () => {
+ it('lists the three panels the settings screens have, in order', () => {
+ expect(SETTINGS_NAV_ITEMS.map((item) => item.key)).toEqual([
+ 'overview',
+ 'devices',
+ 'security',
+ ])
+ })
+
+ it('gives every item an href under the settings root', () => {
+ for (const item of SETTINGS_NAV_ITEMS) {
+ expect(item.href.startsWith(`${SETTINGS_ROOT_HREF}/`)).toBe(true)
+ }
+ })
+
+ it('mentions no locale anywhere', () => {
+ // The prefix is the router's rewrite and `MigrationLink`'s job. An href
+ // spelled `/pl/settings/...` here would be localized twice.
+ for (const item of SETTINGS_NAV_ITEMS) {
+ for (const href of [item.href, ...item.aliases]) {
+ expect(href).not.toMatch(/^\/[a-z]{2}\//)
+ }
+ }
+ })
+
+ it('answers each panel href with its own key', () => {
+ expect(activeSettingsNavKey('/settings/overview')).toBe('overview')
+ expect(activeSettingsNavKey('/settings/devices')).toBe('devices')
+ expect(activeSettingsNavKey('/settings/security')).toBe('security')
+ })
+
+ it('resolves a key back to the href the menu renders', () => {
+ for (const item of SETTINGS_NAV_ITEMS) {
+ expect(settingsNavHref(item.key)).toBe(item.href)
+ }
+ })
+
+ /**
+ * The alias, which is the whole of `/settings`' active-state behaviour: the
+ * root screen renders the overview panel, so the menu has to show *Overview*
+ * as current on it. Without this the root screen is a menu with nothing
+ * selected.
+ */
+ it('marks Overview as current on the settings root', () => {
+ expect(activeSettingsNavKey(SETTINGS_ROOT_HREF)).toBe('overview')
+ })
+
+ it('ignores a trailing slash, which is not a different page', () => {
+ expect(activeSettingsNavKey('/settings/')).toBe('overview')
+ expect(activeSettingsNavKey('/settings/security/')).toBe('security')
+ expect(isSettingsRootPath('/settings/')).toBe(true)
+ })
+
+ it('selects nothing outside the settings screens', () => {
+ expect(activeSettingsNavKey('/files')).toBeUndefined()
+ expect(activeSettingsNavKey('/')).toBeUndefined()
+ // A settings path with no menu entry - a panel reachable by URL before it is
+ // listed. Nothing selected is the honest answer.
+ expect(activeSettingsNavKey('/settings/notifications')).toBeUndefined()
+ })
+
+ it('never lights up a panel from a longer path that starts with it', () => {
+ // A prefix rule would mark Security current on a child of it, which is the
+ // same mistake `isTanStackOwnedPath` guards against for ownership.
+ expect(activeSettingsNavKey('/settings/security/sessions')).toBeUndefined()
+ })
+
+ it('is the root only at the root', () => {
+ expect(isSettingsRootPath(SETTINGS_ROOT_HREF)).toBe(true)
+ expect(isSettingsRootPath('/settings/overview')).toBe(false)
+ expect(isSettingsRootPath('/settingsx')).toBe(false)
+ })
+
+ it('marks exactly one item active on any settings path', () => {
+ for (const pathname of [
+ SETTINGS_ROOT_HREF,
+ '/settings/overview',
+ '/settings/devices',
+ '/settings/security',
+ ]) {
+ const active = SETTINGS_NAV_ITEMS.filter((item) =>
+ isSettingsNavItemActive(item, pathname),
+ )
+
+ expect(active).toHaveLength(1)
+ }
+ })
+})
+
+/**
+ * The route tree beneath the settings layout.
+ *
+ * `matchRoutes` runs no `beforeLoad`, so these paths are matched without a
+ * session. What is being asserted is the parent chain - that every panel is
+ * inside the shell, inside the session guard and inside the settings layout -
+ * rather than access.
+ */
+describe('every settings panel is a child of the layout and the guard', () => {
+ const matchedIds = (pathname: string): string[] =>
+ getRouter()
+ .matchRoutes(pathname, undefined)
+ .map((match) => match.routeId)
+
+ it.each([
+ '/settings',
+ '/settings/overview',
+ '/settings/devices',
+ '/settings/security',
+ ])(
+ '%s renders inside the shell, the guard and the settings layout',
+ (path) => {
+ expect(matchedIds(path)).toEqual(
+ expect.arrayContaining([
+ '/_main',
+ '/_main/_authenticated',
+ '/_main/_authenticated/settings',
+ ]),
+ )
+ },
+ )
+
+ /**
+ * Every destination the menu offers is one this app renders itself.
+ *
+ * This is what "the settings navigation is ordinary owned-route navigation"
+ * amounts to, stated as a property rather than as a choice of component. The
+ * menu is handed `MigrationLink`, which asks the route tree per href and does a
+ * full document load into the Next.js app for anything this one does not serve
+ * - correct behaviour, and invisible when it happens. With every Stage 9 panel
+ * migrated the answer should now be "owned" for all of them, so this fails if a
+ * menu entry is added without a route behind it, or if a panel's route is moved
+ * out from under the layout.
+ */
+ it.each(SETTINGS_NAV_ITEMS)(
+ 'the $key menu entry is a client-side navigation',
+ ({ href }) => {
+ expect(isTanStackOwnedPath(getRouter(), href)).toBe(true)
+ },
+ )
+
+ it('and so is the settings root the menu falls back to', () => {
+ expect(isTanStackOwnedPath(getRouter(), SETTINGS_ROOT_HREF)).toBe(true)
+ })
+
+ /**
+ * The alias, at the level of the route tree: `/settings` is served by the
+ * layout's *index* child rather than by a redirect, so it is a page in its own
+ * right and the deepest match consumes the whole path.
+ */
+ it('serves the settings root from an index child, not a redirect', () => {
+ expect(matchedIds('/settings').at(-1)).toBe(
+ '/_main/_authenticated/settings/',
+ )
+ expect(withoutComments(`${settingsDir}/index.tsx`)).not.toContain(
+ 'redirect',
+ )
+ })
+
+ it('renders the same panel component at the root and at /settings/overview', () => {
+ // The visible half of the alias. Two routes, one component - so the two URLs
+ // cannot drift into two different overview screens.
+ for (const file of ['index.tsx', 'overview.tsx']) {
+ expect(withoutComments(`${settingsDir}/${file}`)).toContain(
+ 'OverviewSettings',
+ )
+ }
+ })
+})
+
+/**
+ * What the panels do *not* do, which in this subtree is most of it.
+ *
+ * The frame, the session check and the robots directive all belong to exactly one
+ * route, and a panel that quietly acquired its own copy of any of them would keep
+ * working while the two copies drifted. A source scan is the honest way to pin
+ * "this file does not contain that", and `withoutComments` is what stops the
+ * prose above each route - which discusses every one of these by name in order to
+ * say where it really lives - from matching.
+ */
+describe('a settings panel owns only its own contents', () => {
+ const panels = ['index.tsx', 'overview.tsx', 'security.tsx', 'devices.tsx']
+
+ it.each(panels)('%s adds no session check of its own', (file) => {
+ const code = withoutComments(`${settingsDir}/${file}`)
+
+ expect(code).not.toContain('ensureAuthState')
+ expect(code).not.toContain('getSession')
+ expect(code).not.toContain('RequireSession')
+ })
+
+ it.each(panels)('%s does not restate the robots directive', (file) => {
+ // The layout declares `noindex, nofollow` once and TanStack Router merges
+ // the `head` of every matched route, so the subtree inherits it.
+ expect(withoutComments(`${settingsDir}/${file}`)).not.toContain('robots')
+ })
+
+ it.each(panels)('%s does not render the shell a second time', (file) => {
+ const code = withoutComments(`${settingsDir}/${file}`)
+
+ expect(code).not.toContain('SettingsShellContent')
+ expect(code).not.toContain('SettingsNavContent')
+ })
+
+ it('declares the robots directive exactly once, on the layout', () => {
+ expect(withoutComments(layoutRoute)).toContain("name: 'robots'")
+ })
+
+ it('puts the session guard nowhere in the subtree', () => {
+ expect(withoutComments(layoutRoute)).not.toContain('ensureAuthState')
+ })
+})
+
+/**
+ * The breadcrumb, as the data each route declares rather than as rendered markup.
+ *
+ * `breadcrumbOf` is already covered in `main-shell.test.ts`; what is new here is
+ * that this is the first subtree to use it for a *nested* trail, so the question
+ * worth asking is which route declares what.
+ */
+describe('the settings breadcrumb is declared by routes, deepest first', () => {
+ const matched = (pathname: string): BreadcrumbMatch[] =>
+ getRouter().matchRoutes(pathname, undefined)
+
+ /**
+ * How many of the matched routes declared a crumb at all.
+ *
+ * Counted rather than collected: `React.ReactNode` includes a promise in React
+ * 19's types, so a helper that *returned* the declarations reads as an async
+ * function to every rule that scans for one.
+ */
+ const declaringMatches = (pathname: string): number =>
+ matched(pathname).filter(
+ (match) => match.staticData.breadcrumb !== undefined,
+ ).length
+
+ /**
+ * The crumb the shell would render, as the element it is.
+ *
+ * Typed as the props this suite reads rather than as `React.ReactNode`: that
+ * type includes a promise in React 19, which makes every function returning
+ * one look like an async component to the rules that scan for them.
+ */
+ const crumbOf = (pathname: string): { props: { navKey?: string } } =>
+ breadcrumbOf(matched(pathname)) as { props: { navKey?: string } }
+
+ it('gives the settings root the layout’s own single crumb', () => {
+ // The index route declares nothing, so the trail falls through to the
+ // layout's - which is what the Next.js `@breadcrumb/settings` slot renders.
+ expect(declaringMatches('/settings')).toBe(1)
+ })
+
+ it.each(['/settings/overview', '/settings/security', '/settings/devices'])(
+ '%s declares its own trail, which wins by being deeper',
+ (pathname) => {
+ expect(declaringMatches(pathname)).toBe(2)
+ },
+ )
+
+ /**
+ * The whole subtree, as the crumb each URL actually resolves to.
+ *
+ * The label comes from the navigation model rather than from a pathname
+ * registry, so what a route declares is the key it already uses for its own
+ * tab title - and `undefined` is the root's answer rather than a missing one,
+ * because the layout's crumb is the single "Settings" trail.
+ *
+ * Stated as one table over all four URLs because this is the seam: the layout,
+ * two panels and the devices panel were written separately, and a crumb that
+ * resolved to the wrong depth would look right on whichever page its author
+ * was reading.
+ */
+ it.each([
+ ['/settings', undefined],
+ ['/settings/overview', 'overview'],
+ ['/settings/devices', 'devices'],
+ ['/settings/security', 'security'],
+ ] as const)('%s resolves to the %s crumb', (pathname, navKey) => {
+ expect(crumbOf(pathname).props.navKey).toBe(navKey)
+ })
+})
diff --git a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
index 829d8ef80..06b619502 100644
--- a/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
+++ b/packages/vitnode/src/api/modules/users/routes/change-password.route.ts
@@ -35,6 +35,15 @@ export const changePasswordRoute = buildRoute({
201: {
description: "Password changed",
},
+ 400: {
+ // Thrown by the handler below when the `userId` + `token` +
+ // unexpired-`expiresAt` lookup finds nothing - a wrong link, a spent one,
+ // or one older than thirty minutes. Declared so the status is part of the
+ // route's contract rather than an undocumented throw a client has to
+ // discover: `fetcher()` types `res.status` from this list, so a caller
+ // cannot branch on a status the route does not admit to.
+ description: "Invalid or expired token",
+ },
},
},
handler: async c => {
diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
index 40f9d75c2..6511dc916 100644
--- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
+++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import {
AlertDialog,
diff --git a/packages/vitnode/src/components/confirm-action/content.tsx b/packages/vitnode/src/components/confirm-action/content.tsx
index 7ae15acee..871933f1a 100644
--- a/packages/vitnode/src/components/confirm-action/content.tsx
+++ b/packages/vitnode/src/components/confirm-action/content.tsx
@@ -1,5 +1,5 @@
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import {
AlertDialogCancel,
diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx
index 16c6d5790..e4745fd2d 100644
--- a/packages/vitnode/src/components/form/auto-form.tsx
+++ b/packages/vitnode/src/components/form/auto-form.tsx
@@ -2,7 +2,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useAnimate, useReducedMotion } from "motion/react";
-import { useTranslations } from "next-intl";
import { useEffect } from "react";
import {
type ControllerRenderProps,
@@ -14,6 +13,7 @@ import {
type UseFormReturn,
useFormState,
} from "react-hook-form";
+import { useTranslations } from "use-intl";
import z from "zod";
import type { routeMiddlewareSchema } from "../../api/modules/middleware/route";
diff --git a/packages/vitnode/src/components/form/common/label.tsx b/packages/vitnode/src/components/form/common/label.tsx
index 7f0bc66a2..8c340cb04 100644
--- a/packages/vitnode/src/components/form/common/label.tsx
+++ b/packages/vitnode/src/components/form/common/label.tsx
@@ -1,4 +1,4 @@
-import { useTranslations } from "next-intl";
+import { useTranslations } from "use-intl";
import { FieldLabel } from "@/components/ui/field";
import { useFormField } from "@/components/ui/form";
diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx
index 7c6c94781..3513dfbab 100644
--- a/packages/vitnode/src/components/form/fields/multi-lang.tsx
+++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx
@@ -2,8 +2,8 @@
import type { ControllerRenderProps, FieldValues } from "react-hook-form";
-import { useLocale, useTranslations } from "next-intl";
import React from "react";
+import { useLocale, useTranslations } from "use-intl";
import type { MultiLangValue } from "@/lib/helpers/multi-lang";
import type { LocaleConfig } from "@/vitnode.config";
diff --git a/packages/vitnode/src/components/switchers/themes/theme-switcher.tsx b/packages/vitnode/src/components/switchers/themes/theme-switcher.tsx
index 000a689c0..f2c758a5f 100644
--- a/packages/vitnode/src/components/switchers/themes/theme-switcher.tsx
+++ b/packages/vitnode/src/components/switchers/themes/theme-switcher.tsx
@@ -1,7 +1,7 @@
"use client";
import { Moon, Sun } from "lucide-react";
-import { useTranslations } from "next-intl";
+import { useTranslations } from "use-intl";
import { useTheme } from "../../theme-provider";
import { Button } from "../../ui/button";
diff --git a/packages/vitnode/src/components/table/content.tsx b/packages/vitnode/src/components/table/content.tsx
index 75734df46..53c72d7e7 100644
--- a/packages/vitnode/src/components/table/content.tsx
+++ b/packages/vitnode/src/components/table/content.tsx
@@ -1,5 +1,4 @@
import { SearchXIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import type {
AlignDataTable,
@@ -18,6 +17,7 @@ import {
TableRow,
} from "../ui/table";
import { FiltersDataTable } from "./filters";
+import { NoResultsDataTable } from "./no-results";
import { OrderTableHeadDataTable } from "./order-table-head";
import { PaginationDataTable } from "./pagination";
import { SearchDataTable } from "./search";
@@ -47,7 +47,6 @@ export function ContentDataTable({
filters,
...props
}: DataTableProps) {
- const t = useTranslations("core.global");
const hasToolbar = Boolean(search) || Boolean(filters?.length);
const allColumns: ColumnDef[] = bulkActions
? [
@@ -149,12 +148,10 @@ export function ContentDataTable({
{customNoResults?.icon ?? }
-
- {customNoResults?.title ?? t("no_results.title")}
-
-
- {customNoResults?.description ?? t("no_results.desc")}
-
+
{customNoResults?.footer}
diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx
index ee583a92c..fff2d9616 100644
--- a/packages/vitnode/src/components/table/filters.tsx
+++ b/packages/vitnode/src/components/table/filters.tsx
@@ -1,9 +1,9 @@
"use client";
import { CheckIcon, PlusCircleIcon, Trash2 } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
+import { useTranslations } from "use-intl";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/components/table/no-results.tsx b/packages/vitnode/src/components/table/no-results.tsx
new file mode 100644
index 000000000..3862c796f
--- /dev/null
+++ b/packages/vitnode/src/components/table/no-results.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useTranslations } from "use-intl";
+
+/**
+ * The data table's default empty state.
+ *
+ * Two strings, and its own `"use client"` module for one reason:
+ * {@link ContentDataTable} is rendered as a *Server Component* by every AdminCP
+ * page - `DataTable` has no client boundary of its own, so React renders the
+ * table on the server and only its controls in the browser - and as an ordinary
+ * client component by `apps/web`, which has no server components at all. It is
+ * therefore the one shared component in this package that cannot read a React
+ * context, because in half its callers there is no context to read.
+ *
+ * `next-intl` used to paper over that: its root entry resolves to an
+ * RSC-capable `useTranslations` under Next's `react-server` condition and to
+ * the context-reading one everywhere else. That works, and it is the only
+ * reason the table translated in both places - but it is also the last thing
+ * tying a shared component to Next.js, and it hid the fact that the table
+ * renders in two different environments.
+ *
+ * So the translating moved here instead, behind a boundary that is a client
+ * component in both frameworks. A caller that already has the copy passes
+ * `customNoResults` and this renders its strings without looking anything up.
+ */
+export const NoResultsDataTable = ({
+ description,
+ title,
+}: {
+ description?: string;
+ title?: string;
+}) => {
+ const t = useTranslations("core.global.no_results");
+
+ return (
+ <>
+
+ {title ?? t("title")}
+
+
+ {description ?? t("desc")}
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/components/table/pagination.tsx b/packages/vitnode/src/components/table/pagination.tsx
index f97795376..969150178 100644
--- a/packages/vitnode/src/components/table/pagination.tsx
+++ b/packages/vitnode/src/components/table/pagination.tsx
@@ -1,8 +1,8 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import { Button } from "../ui/button";
import {
diff --git a/packages/vitnode/src/components/table/search.tsx b/packages/vitnode/src/components/table/search.tsx
index eb096ff45..65afcf6b6 100644
--- a/packages/vitnode/src/components/table/search.tsx
+++ b/packages/vitnode/src/components/table/search.tsx
@@ -1,9 +1,9 @@
"use client";
import { Search } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
+import { useTranslations } from "use-intl";
import {
InputGroup,
diff --git a/packages/vitnode/src/components/table/selection.tsx b/packages/vitnode/src/components/table/selection.tsx
index 2c60fac79..3f651e65b 100644
--- a/packages/vitnode/src/components/table/selection.tsx
+++ b/packages/vitnode/src/components/table/selection.tsx
@@ -2,9 +2,9 @@
import { XIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
-import { useTranslations } from "next-intl";
import React from "react";
import { createPortal } from "react-dom";
+import { useTranslations } from "use-intl";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
diff --git a/packages/vitnode/src/components/ui/alert-dialog.tsx b/packages/vitnode/src/components/ui/alert-dialog.tsx
index a3b00eedc..59a251ddc 100644
--- a/packages/vitnode/src/components/ui/alert-dialog.tsx
+++ b/packages/vitnode/src/components/ui/alert-dialog.tsx
@@ -1,8 +1,8 @@
"use client";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/components/ui/button-client.tsx b/packages/vitnode/src/components/ui/button-client.tsx
index 3f524217e..b9049d46f 100644
--- a/packages/vitnode/src/components/ui/button-client.tsx
+++ b/packages/vitnode/src/components/ui/button-client.tsx
@@ -2,7 +2,7 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { AnimatePresence, motion } from "motion/react";
-import { useTranslations } from "next-intl";
+import { useTranslations } from "use-intl";
import { cn } from "../../lib/utils";
import { type ButtonProps, buttonVariants } from "./button";
diff --git a/packages/vitnode/src/components/ui/dialog.tsx b/packages/vitnode/src/components/ui/dialog.tsx
index 09048f086..a47e4ddd8 100644
--- a/packages/vitnode/src/components/ui/dialog.tsx
+++ b/packages/vitnode/src/components/ui/dialog.tsx
@@ -2,8 +2,8 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { XIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/components/ui/form.tsx b/packages/vitnode/src/components/ui/form.tsx
index 4bef7f1c1..b1c600a44 100644
--- a/packages/vitnode/src/components/ui/form.tsx
+++ b/packages/vitnode/src/components/ui/form.tsx
@@ -2,7 +2,6 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
-import { useTranslations } from "next-intl";
import React from "react";
import {
Controller,
@@ -14,6 +13,7 @@ import {
useFormContext,
useFormState,
} from "react-hook-form";
+import { useTranslations } from "use-intl";
import { cn } from "@/lib/utils";
diff --git a/packages/vitnode/src/lib/api/get-devices-api.ts b/packages/vitnode/src/lib/api/get-devices-api.ts
deleted file mode 100644
index 7f1ea391a..000000000
--- a/packages/vitnode/src/lib/api/get-devices-api.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { usersModule } from "@/api/modules/users/users.module";
-import { fetcher } from "@/lib/fetcher";
-
-export const getDevicesApi = async () => {
- const res = await fetcher(usersModule, {
- path: "/devices",
- method: "get",
- module: "users",
- });
-
- const data = await res.json();
-
- return data;
-};
-
-export type DevicesApi = Awaited>;
diff --git a/packages/vitnode/src/lib/i18n/rsc-boundaries.test.ts b/packages/vitnode/src/lib/i18n/rsc-boundaries.test.ts
new file mode 100644
index 000000000..c25385c3f
--- /dev/null
+++ b/packages/vitnode/src/lib/i18n/rsc-boundaries.test.ts
@@ -0,0 +1,200 @@
+// @vitest-environment node
+import { existsSync, readdirSync, 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, "../..");
+const repoRoot = resolve(srcRoot, "../../..");
+
+/**
+ * Where a shared component may read `use-intl`, and where it may not.
+ *
+ * `useTranslations` from `use-intl` is a React context read, and a React Server
+ * Component has no context. `next-intl`'s root entry hides that difference - it
+ * resolves to an RSC-capable implementation under Next's `react-server`
+ * condition and to the context-reading one everywhere else - so a component
+ * that reads through it translates in both environments without anybody having
+ * to know which one it is in.
+ *
+ * That is convenient and it is exactly the thing this migration is removing:
+ * every component `apps/web` renders now imports `use-intl` directly, because
+ * TanStack Start has no `react-server` condition and the `next-intl` root entry
+ * was the last Next.js dependency in the shared tree.
+ *
+ * The trade is that the environment now matters, and `ContentDataTable` is the
+ * component that proves it: `DataTable` mounts no client boundary of its own,
+ * so React renders the AdminCP's table *on the server* while `apps/web` renders
+ * the same component in the browser. Swapping its `next-intl` import for
+ * `use-intl` compiled, type-checked, passed every test in this repository and
+ * broke every AdminCP table - which is why the check is here rather than in a
+ * reviewer's head. Its two strings now live in `NoResultsDataTable`, behind
+ * `"use client"`.
+ *
+ * The rule, then: **a module React renders on the server may not read
+ * `use-intl`.** It may take its copy as a prop, or delegate to a client leaf
+ * that reads it.
+ */
+
+const SKIP_DIRECTORIES = new Set([
+ ".next",
+ ".output",
+ ".source",
+ ".turbo",
+ "dist",
+ "node_modules",
+]);
+
+/** Next's own file conventions - every module React can render from. */
+const ENTRY_FILE =
+ /\/(page|layout|template|route|not-found|error|global-error|default|loading|opengraph-image|sitemap|robots)\.tsx?$/;
+
+const filesUnder = (directory: string): string[] => {
+ if (!existsSync(directory)) return [];
+
+ const entries: string[] = [];
+
+ for (const name of readdirSync(directory)) {
+ const path = join(directory, name);
+
+ if (statSync(path).isDirectory()) {
+ if (!SKIP_DIRECTORIES.has(name)) entries.push(...filesUnder(path));
+ continue;
+ }
+
+ if (
+ /\.tsx?$/.test(name) &&
+ !name.endsWith(".d.ts") &&
+ !/\.test\.tsx?$/.test(name)
+ ) {
+ entries.push(path);
+ }
+ }
+
+ return entries;
+};
+
+const isClientModule = (path: string): boolean =>
+ /^\s*["']use client["']/.test(readFileSync(path, "utf8"));
+
+/**
+ * Every specifier a file imports at runtime.
+ *
+ * `import type` is stripped first: the compiler erases it, so it is not part of
+ * the graph React renders.
+ */
+const importsFrom = (path: string): string[] => {
+ const source = readFileSync(path, "utf8");
+
+ return [
+ ...source.matchAll(
+ /(?:^|[^\w$.])from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']/g,
+ ),
+ ]
+ .filter(match => {
+ const before = source.slice(
+ Math.max(0, (match.index ?? 0) - 220),
+ match.index,
+ );
+ const statement = before.lastIndexOf("import");
+
+ return (
+ statement === -1 || !/^import\s+type\b/.test(before.slice(statement))
+ );
+ })
+ .map(match => match[1] ?? match[2])
+ .filter((specifier): specifier is string => Boolean(specifier));
+};
+
+const resolveSpecifier = (specifier: string, from: string): null | string => {
+ let base: string;
+
+ if (specifier.startsWith("@/")) base = join(srcRoot, specifier.slice(2));
+ else if (specifier.startsWith("@vitnode/core/")) {
+ base = join(srcRoot, specifier.slice("@vitnode/core/".length));
+ } 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 null;
+};
+
+/**
+ * Every module React renders on the server, and the entry that reaches it.
+ *
+ * Walks out from each Next entry point and **stops at every `"use client"`
+ * boundary** - which is precisely React's own rule for what runs where.
+ */
+const serverRenderedModules = (): Map => {
+ const entries = [
+ ...filesUnder(join(srcRoot, "routes")),
+ ...filesUnder(join(repoRoot, "apps/docs/src")),
+ ...filesUnder(join(repoRoot, "plugins/blog/src/routes")),
+ ...filesUnder(join(repoRoot, "plugins/example/src/routes")),
+ ].filter(path => ENTRY_FILE.test(path) && !isClientModule(path));
+
+ const reached = new Map();
+ const stack: { entry: string; module: string }[] = entries.map(entry => ({
+ entry,
+ module: entry,
+ }));
+
+ for (let next = stack.pop(); next; next = stack.pop()) {
+ const { entry, module } = next;
+ if (reached.has(module)) continue;
+ reached.set(module, entry);
+
+ for (const specifier of importsFrom(module)) {
+ const target = resolveSpecifier(specifier, module);
+ if (target && !isClientModule(target))
+ stack.push({ entry, module: target });
+ }
+ }
+
+ return reached;
+};
+
+describe("the server-rendered half of the package never reads a React context", () => {
+ const modules = serverRenderedModules();
+
+ it("finds the Next.js entry points it is walking from", () => {
+ // Every assertion below is a "found nothing" one, which a walk that reached
+ // nothing also satisfies.
+ expect(modules.size).toBeGreaterThan(100);
+ expect(
+ [...modules.keys()].some(path =>
+ path.endsWith("components/table/content.tsx"),
+ ),
+ "the AdminCP tables reach ContentDataTable on the server",
+ ).toBe(true);
+ });
+
+ it("stops at every client boundary", () => {
+ // The control: `AutoForm` is `"use client"`, so nothing below it is server
+ // rendered even though a Server Component page renders one.
+ expect(
+ [...modules.keys()].filter(path =>
+ path.endsWith("components/form/auto-form.tsx"),
+ ),
+ ).toEqual([]);
+ });
+
+ it("reads use-intl from nowhere React renders on the server", () => {
+ const offenders = [...modules.entries()]
+ .filter(([path]) =>
+ /(?:^|[^\w$.])from\s*["']use-intl["']/.test(readFileSync(path, "utf8")),
+ )
+ .map(
+ ([path, entry]) =>
+ `${relative(repoRoot, path)} (rendered by ${relative(repoRoot, entry)})`,
+ );
+
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/packages/vitnode/src/views/auth/auth-boundaries.test.ts b/packages/vitnode/src/views/auth/auth-boundaries.test.ts
index 2a7ff9a9d..5ea42e14b 100644
--- a/packages/vitnode/src/views/auth/auth-boundaries.test.ts
+++ b/packages/vitnode/src/views/auth/auth-boundaries.test.ts
@@ -17,9 +17,27 @@ const srcRoot = resolve(here, "../..");
* visible until somebody tries.
*/
const SHARED = {
+ breadcrumbTrail: join(here, "../breadcrumb/breadcrumb-main-content.tsx"),
card: join(here, "sign-in/sign-in-content.tsx"),
+ changePasswordForm: join(
+ here,
+ "password-reset/change-password-form/change-password-form-content.tsx",
+ ),
errorScreen: join(here, "../error/error-content.tsx"),
+ passwordResetCard: join(here, "password-reset/password-reset-content.tsx"),
+ passwordResetForm: join(
+ here,
+ "password-reset/form/password-reset-form-content.tsx",
+ ),
+ recoveryLink: join(here, "password-reset/recovery-link.ts"),
+ settingsNav: join(here, "settings/nav-content.tsx"),
+ settingsNavModel: join(here, "settings/settings-nav.ts"),
+ settingsOverview: join(here, "settings/overview/overview.tsx"),
+ settingsSecurity: join(here, "settings/security/security.tsx"),
+ settingsShell: join(here, "settings/shell-content.tsx"),
signInForm: join(here, "sign-in/form/sign-in-form-content.tsx"),
+ signUpCard: join(here, "sign-up/sign-up-content.tsx"),
+ signUpForm: join(here, "sign-up/form/sign-up-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"),
@@ -27,8 +45,18 @@ const SHARED = {
/** The Next.js half: server actions, `next/cache`, locale-aware navigation. */
const NEXT_WRAPPERS = {
+ breadcrumbTrail: join(here, "../breadcrumb/breadcrumb-main.tsx"),
card: join(here, "sign-in/sign-in-card.tsx"),
+ changePasswordForm: join(
+ here,
+ "password-reset/change-password-form/form.tsx",
+ ),
+ passwordResetForm: join(here, "password-reset/form/form.tsx"),
+ settingsNav: join(here, "settings/nav.tsx"),
+ settingsShell: join(here, "settings/shell.tsx"),
signInForm: join(here, "sign-in/form/form.tsx"),
+ signUpCard: join(here, "sign-up/sign-up-card.tsx"),
+ signUpForm: join(here, "sign-up/form/form.tsx"),
ssoButtons: join(here, "sso/buttons/client.tsx"),
ssoCallback: join(here, "sso/callback/client/client.tsx"),
};
@@ -213,11 +241,50 @@ describe("the shared views take their framework parts as props", () => {
});
it("takes its links as a component in every view that renders one", () => {
- for (const path of [SHARED.card, SHARED.signInForm, SHARED.ssoCallback]) {
+ for (const path of [
+ SHARED.card,
+ SHARED.signInForm,
+ SHARED.signUpCard,
+ SHARED.signUpForm,
+ SHARED.ssoCallback,
+ ]) {
expect(withoutComments(path)).toContain("LinkComponent");
}
});
+ it("asks for a sign-up callback rather than calling a mutation", () => {
+ const code = withoutComments(SHARED.signUpForm);
+
+ expect(code).toContain("onSignUp");
+ expect(code).not.toContain("mutationApi");
+ });
+
+ it("asks for the two recovery mutations as callbacks", () => {
+ expect(withoutComments(SHARED.passwordResetForm)).toContain(
+ "onRequestReset",
+ );
+ expect(withoutComments(SHARED.changePasswordForm)).toContain(
+ "onChangePassword",
+ );
+ });
+
+ it("takes where to go after a password change as a callback", () => {
+ // The API mints no session on a password change, so the visitor goes to the
+ // login page - but `useRouter().replace` is Next-only and the router
+ // navigation is TanStack-only, so the trip itself is the caller's.
+ const code = withoutComments(SHARED.changePasswordForm);
+
+ expect(code).toContain("onChanged");
+ expect(code).not.toContain("useRouter");
+ });
+
+ it("takes an already-parsed recovery link rather than raw search params", () => {
+ const code = withoutComments(SHARED.changePasswordForm);
+
+ expect(code).toContain("link: RecoveryLink;");
+ expect(code).not.toContain("userId: string");
+ });
+
it("renders the callback from a state rather than owning the request", () => {
const code = withoutComments(SHARED.ssoCallback);
@@ -250,15 +317,92 @@ describe("the Next wrappers keep the Next-only pieces", () => {
});
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);
+ for (const path of [
+ NEXT_WRAPPERS.changePasswordForm,
+ NEXT_WRAPPERS.passwordResetForm,
+ NEXT_WRAPPERS.signInForm,
+ NEXT_WRAPPERS.signUpForm,
+ NEXT_WRAPPERS.ssoCallback,
+ ]) {
+ expect(
+ runtimeImports(path).some(one => one.includes("mutation-api.server")),
+ ).toBe(true);
+ }
+ });
+});
+
+/**
+ * The settings screens, split the same way.
+ *
+ * `SettingsShell` was visually reusable and structurally Next-only: it read
+ * `usePathname` to decide the narrow-screen behaviour, it imported `next-intl`'s
+ * `Link` for the back link, and it imported the navigation, which read the same
+ * pathname a second time for the active item. Three separate reasons a TanStack
+ * Start layout route could not render it, and none of them visible in what it
+ * looks like.
+ *
+ * What replaced them is one rule: the frame and the menu are *told* where the
+ * visitor is and how to build a link. The assertions below are about that shape
+ * as well as about the absence of a specifier, because a shared component can
+ * also fail by taking the wrong thing as a prop.
+ */
+describe("the settings frame is told its framework parts", () => {
+ const withoutComments = (path: string): string =>
+ readFileSync(path, "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/\/\/.*$/gm, "");
+
+ it("takes the navigation as a slot and the back link as a component", () => {
+ const code = withoutComments(SHARED.settingsShell);
+
+ expect(code).toContain("nav: React.ReactNode;");
+ expect(code).toContain("BackLink: AuthLinkComponent;");
+ });
+
+ it("takes where it is as a prop rather than asking", () => {
+ // The one decision neither half can make for itself. `isSettingsRootPath`
+ // and the active-item rule are shared; reading the pathname is not.
+ for (const path of [SHARED.settingsShell, SHARED.settingsNav]) {
+ expect(withoutComments(path)).not.toContain("usePathname");
+ }
+
+ expect(withoutComments(SHARED.settingsShell)).toContain("isRoot: boolean;");
+ expect(withoutComments(SHARED.settingsNav)).toContain("pathname: string;");
+ });
+
+ it("takes its links as a component in the menu and in the breadcrumb", () => {
+ for (const path of [SHARED.settingsNav, SHARED.breadcrumbTrail]) {
+ expect(withoutComments(path)).toContain("LinkComponent");
+ }
+ });
+
+ it("keeps the menu and the active-item rule as data, not markup", () => {
+ // `settings-nav.ts` is what both frameworks agree through, so it must stay
+ // free of React as well as of Next: a model that rendered would be a third
+ // navigation nobody meant to have.
+ const reached = [...externalGraph(SHARED.settingsNavModel).keys()];
+
+ expect(reached).not.toContain("react");
+ expect(reached.some(one => one.includes("intl"))).toBe(false);
+ expect(withoutComments(SHARED.settingsNav)).toContain("settings-nav");
+ });
+
+ it("reads its strings from use-intl rather than from a request", () => {
+ // The two panels were Server Components calling `getTranslations`, which is
+ // what made a heading Next-only. The scans above pin the absence of that;
+ // this pins what took its place, so a panel cannot pass by translating
+ // nothing at all.
+ for (const path of [SHARED.settingsOverview, SHARED.settingsSecurity]) {
+ expect(runtimeImports(path)).toContain("use-intl");
+ }
+ });
+
+ it("is the Next wrappers that know where the visitor is", () => {
+ for (const path of [
+ NEXT_WRAPPERS.settingsNav,
+ NEXT_WRAPPERS.settingsShell,
+ ]) {
+ expect(withoutComments(path)).toContain("usePathname");
+ }
});
});
diff --git a/packages/vitnode/src/views/auth/auth-link.ts b/packages/vitnode/src/views/auth/auth-link.ts
index e91933393..293b7a27b 100644
--- a/packages/vitnode/src/views/auth/auth-link.ts
+++ b/packages/vitnode/src/views/auth/auth-link.ts
@@ -34,9 +34,13 @@ export type AuthLinkComponent = (props: AuthLinkProps) => React.ReactNode;
*
* 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.
+ * about it.
+ *
+ * Nothing here records which application serves any of them either, and that is
+ * the point rather than an omission. All three were Next.js pages when this was
+ * written and all three are TanStack Start routes now; in that app they are
+ * reached through the migration link, which asks the route tree per href, so the
+ * change was route files and no edit to this record.
*/
export const AUTH_HREF = {
resetPassword: "/login/reset-password",
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx b/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx
new file mode 100644
index 000000000..4938f6351
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useTranslations } from "use-intl";
+
+import { AutoForm } from "@/components/form/auto-form";
+import {
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+import type { RecoveryLink } from "../recovery-link";
+
+import { PasswordInput } from "../../sign-up/components/password-input";
+import {
+ type ChangePasswordSubmit,
+ useChangePasswordForm,
+} from "./use-change-password-form";
+
+export type { ChangePasswordSubmit };
+
+/**
+ * The second half of password recovery - shared.
+ *
+ * One field, and two props that are the framework boundary: the mutation, and
+ * what to do once the password has changed. The form no longer imports a server
+ * action or `@/lib/navigation`, so a TanStack Start route renders exactly the
+ * card the Next.js page renders.
+ *
+ * `link` is already parsed - see `../recovery-link.ts`. A route that could not
+ * parse one must render the request form instead, which is a decision for the
+ * page rather than for this component: there is no such thing as this screen
+ * without a link.
+ *
+ * No captcha: the API's change-password route does not ask for one
+ * (`withCaptcha` is absent), because the token in the link is the thing being
+ * checked.
+ */
+export const ChangePasswordFormContent = ({
+ link,
+ onChanged,
+ onChangePassword,
+}: {
+ link: RecoveryLink;
+ onChanged: () => void;
+ onChangePassword: ChangePasswordSubmit;
+}) => {
+ const t = useTranslations("core.auth.change_password");
+ const tSignUp = useTranslations("core.auth.sign_up");
+ const { formSchema, onSubmit } = useChangePasswordForm({
+ link,
+ onChanged,
+ onChangePassword,
+ });
+
+ return (
+ <>
+
+
+ {t("title")}
+
+ {t("desc")}
+
+
+
+ (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={onSubmit}
+ submitButtonProps={{
+ className: "w-full",
+ children: t("submit"),
+ }}
+ />
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/form.tsx b/packages/vitnode/src/views/auth/password-reset/change-password-form/form.tsx
index 715270be3..d8e8e3cc9 100644
--- a/packages/vitnode/src/views/auth/password-reset/change-password-form/form.tsx
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/form.tsx
@@ -1,53 +1,31 @@
"use client";
-import { useTranslations } from "next-intl";
+import { useRouter } from "@/lib/navigation";
-import { AutoForm } from "@/components/form/auto-form";
-import {
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
+import type { RecoveryLink } from "../recovery-link";
-import { PasswordInput } from "../../sign-up/components/password-input";
-import { useForm } from "./use-form";
+import { AUTH_HREF } from "../../auth-link";
+import { ChangePasswordFormContent } from "./change-password-form-content";
+import { mutationApi } from "./mutation-api.server";
-export const ChangePasswordForm = (props: {
- token: string;
- userId: string;
-}) => {
- const t = useTranslations("core.auth.change_password");
- const tSignUp = useTranslations("core.auth.sign_up");
- const { formSchema, onSubmit } = useForm(props);
+/**
+ * {@link ChangePasswordFormContent}, wired to Next.js.
+ *
+ * Two props, both Next-only: the server action, and `next-intl`'s locale-aware
+ * `replace` for the trip to the login page once the password has changed. The
+ * API mints no session on that change, so leaving for the login form is the
+ * whole of the success path.
+ */
+export const ChangePasswordForm = ({ link }: { link: RecoveryLink }) => {
+ const { replace } = useRouter();
return (
- <>
-
-
- {t("title")}
-
- {t("desc")}
-
-
-
- (
-
- ),
- },
- ]}
- formSchema={formSchema}
- onSubmit={onSubmit}
- submitButtonProps={{
- className: "w-full",
- children: t("submit"),
- }}
- />
-
- >
+ {
+ replace(AUTH_HREF.signIn);
+ }}
+ onChangePassword={mutationApi}
+ />
);
};
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/mutation-api.server.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/mutation-api.server.ts
index e47effa9c..ba2969a6a 100644
--- a/packages/vitnode/src/views/auth/password-reset/change-password-form/mutation-api.server.ts
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/mutation-api.server.ts
@@ -1,17 +1,30 @@
"use server";
-import type z from "zod";
-
-import type { zodChangePasswordSchema } from "@/api/modules/users/routes/change-password.route";
-
import { usersModule } from "@/api/modules/users/users.module";
import { fetcher } from "@/lib/fetcher";
+import type {
+ ChangePasswordMutationResult,
+ ChangePasswordSubmitValues,
+} from "./schema";
+
+/**
+ * Setting a new password from a recovery link, for Next.js.
+ *
+ * `400` is kept apart from everything else: it is the API's answer when the
+ * `userId` + `token` + unexpired-`expiresAt` lookup finds nothing, which means
+ * the link is wrong, spent or older than thirty minutes. The API's own message
+ * stays on the server; only the literal travels.
+ *
+ * No `allowSaveCookies` and no revalidation, because the API mints no session
+ * here - the visitor is still signed out, and the form sends them to the login
+ * page.
+ */
export const mutationApi = async ({
password,
token,
userId,
-}: z.infer) => {
+}: ChangePasswordSubmitValues): Promise => {
const res = await fetcher(usersModule, {
module: "users",
path: "/change-password",
@@ -21,7 +34,8 @@ export const mutationApi = async ({
},
});
- if (res.status !== 201) {
- return { error: "internal_server_error" };
- }
+ if (res.status === 400) return { message: "invalid_token" };
+ if (res.status !== 201) return { message: "internal_server_error" };
+
+ return undefined;
};
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.test.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.test.ts
new file mode 100644
index 000000000..b2e885ba3
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ changePasswordFormOutcome,
+ createChangePasswordFormSchema,
+} from "./schema";
+
+const schema = createChangePasswordFormSchema({
+ fieldRequired: "required",
+ invalidPassword: "too weak",
+});
+
+describe("the change-password schema", () => {
+ it("applies the registration form's password rules", () => {
+ // Imported rather than restated, so this is really a test that the two
+ // screens cannot drift apart on what a strong password is.
+ expect(schema.safeParse({ password: "Test123!" }).success).toBe(true);
+ expect(schema.safeParse({ password: "test" }).success).toBe(false);
+ });
+
+ it("rejects a weak password with the message it was given", () => {
+ const parsed = schema.safeParse({ password: "test1234" });
+
+ expect(parsed.error?.issues[0]?.message).toBe("too weak");
+ });
+
+ it("asks for nothing but the password", () => {
+ // The token and the account id come from the URL, not from a field, which is
+ // why they are not in this schema at all.
+ expect(Object.keys(schema.shape)).toEqual(["password"]);
+ });
+});
+
+describe("what a submit result means for the screen", () => {
+ it("reads success as success, and leaves the navigation to the caller", () => {
+ expect(changePasswordFormOutcome(undefined)).toEqual({ kind: "success" });
+ });
+
+ it("keeps an unusable link apart from a server failure", () => {
+ // The visitor can act on the first (ask for a fresh link) and not on the
+ // second, which is the whole reason the distinction survives this far.
+ expect(changePasswordFormOutcome({ message: "invalid_token" })).toEqual({
+ kind: "toast",
+ reason: "invalid_token",
+ });
+ expect(
+ changePasswordFormOutcome({ message: "internal_server_error" }),
+ ).toEqual({ kind: "toast", reason: "server" });
+ });
+});
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts
new file mode 100644
index 000000000..8f9d087c7
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts
@@ -0,0 +1,77 @@
+import { z } from "zod";
+
+import type { PasswordFieldMessages } from "../../sign-up/form/schema";
+import type { RecoveryLink } from "../recovery-link";
+
+import { createPasswordZodSchema } from "../../sign-up/form/schema";
+
+/**
+ * The "choose a new password" form's shape and its failure vocabulary, with no
+ * React in sight.
+ *
+ * The password rules are *imported* rather than restated. They are the
+ * registration form's rules - one function of two translated strings in
+ * `sign-up/form/schema.ts` - and a second copy here would be a second answer to
+ * "what is a strong enough password", which is precisely the kind of pair that
+ * drifts.
+ */
+
+export type ChangePasswordFormMessages = PasswordFieldMessages;
+
+export const createChangePasswordFormSchema = (
+ messages: ChangePasswordFormMessages,
+) =>
+ z.object({
+ password: createPasswordZodSchema(messages),
+ });
+
+export type ChangePasswordFormSchema = ReturnType<
+ typeof createChangePasswordFormSchema
+>;
+export type ChangePasswordFormValues = z.infer;
+
+/**
+ * What the form sends: the new password, plus the link it is acting on.
+ *
+ * The link travels as a {@link RecoveryLink} - already parsed, `userId` already
+ * a number - rather than as the raw search parameters, so a screen cannot hand
+ * the transport a `userId` of `"abc"` and no layer has to coerce one. See
+ * `../recovery-link.ts`.
+ */
+export type ChangePasswordSubmitValues = RecoveryLink & { password: string };
+
+/**
+ * What the API told us about a password change.
+ *
+ * `undefined` is success. `'invalid_token'` is the API's `400`: the row it looks
+ * up by `userId` + `token` + an unexpired `expiresAt` was not there, which means
+ * the link was wrong, already used, or older than thirty minutes. It is kept
+ * apart from the generic failure because it is the one a visitor can act on -
+ * ask for a fresh link - whereas a `500` is nothing they can do anything about.
+ *
+ * The API's own message (`"Invalid token"`) never travels; only this literal
+ * does.
+ */
+export type ChangePasswordMutationResult =
+ undefined | { message: "internal_server_error" | "invalid_token" };
+
+/**
+ * What a submit result means for the screen.
+ *
+ * - `"success"` - raise the success toast and leave for the login page. The API
+ * does *not* sign the visitor in (`users/routes/change-password.route.ts`
+ * mints no session), so the next step is genuinely to log in.
+ * - `"toast"` - a failure toast, with `reason` deciding which message. The form
+ * stays where it is either way.
+ */
+export const changePasswordFormOutcome = (
+ result: ChangePasswordMutationResult,
+):
+ | { kind: "success" }
+ | { kind: "toast"; reason: "invalid_token" | "server" } =>
+ result?.message
+ ? {
+ kind: "toast",
+ reason: result.message === "invalid_token" ? "invalid_token" : "server",
+ }
+ : { kind: "success" };
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts
new file mode 100644
index 000000000..2c2e5bc70
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts
@@ -0,0 +1,101 @@
+"use client";
+
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { AutoFormOnSubmit } from "@/components/form/auto-form";
+
+import type { RecoveryLink } from "../recovery-link";
+import type {
+ ChangePasswordFormSchema,
+ ChangePasswordMutationResult,
+ ChangePasswordSubmitValues,
+} from "./schema";
+
+import {
+ changePasswordFormOutcome,
+ createChangePasswordFormSchema,
+} from "./schema";
+
+export type { ChangePasswordSubmitValues };
+
+/**
+ * How the form sets a new password.
+ *
+ * The whole of the framework boundary for password recovery's second half. It
+ * takes the new password together with the already-parsed link it is acting on,
+ * and answers what happened. Next.js calls a server action; TanStack Start calls
+ * a server function.
+ */
+export type ChangePasswordSubmit = (
+ values: ChangePasswordSubmitValues,
+) => Promise;
+
+/**
+ * The change-password form's behaviour, with no idea which framework is
+ * rendering it.
+ *
+ * Two props, and both are things this side cannot answer:
+ *
+ * - `onChangePassword` - the mutation.
+ * - `onChanged` - where to go afterwards. The API mints **no session** on a
+ * successful change, so the visitor is still signed out and the only sensible
+ * destination is the login page - but *how* to get there is `useRouter().replace`
+ * in Next.js and a router navigation in TanStack Start, so the caller does it.
+ *
+ * `link` is a {@link RecoveryLink}, which means it has already been through
+ * `parseRecoveryLink`: this hook never sees a raw search parameter and never
+ * coerces one.
+ *
+ * The toasts stay on this side deliberately - they are the same two messages for
+ * the same two reasons in both frameworks. An expired or already-used link gets
+ * the `400` copy rather than the generic internal-error copy, because it is the
+ * one failure a visitor can act on: ask for a fresh link.
+ */
+export const useChangePasswordForm = ({
+ link,
+ onChanged,
+ onChangePassword,
+}: {
+ link: RecoveryLink;
+ onChanged: () => void;
+ onChangePassword: ChangePasswordSubmit;
+}) => {
+ const t = useTranslations("core.auth.change_password");
+ const tSignUp = useTranslations("core.auth.sign_up");
+ const tErrors = useTranslations("core.global.errors");
+
+ const formSchema = createChangePasswordFormSchema({
+ fieldRequired: tErrors("field_required"),
+ invalidPassword: tSignUp("password.invalid"),
+ });
+
+ const onSubmit: AutoFormOnSubmit = async ({
+ password,
+ }) => {
+ const outcome = changePasswordFormOutcome(
+ await onChangePassword({ ...link, password }),
+ );
+
+ if (outcome.kind === "toast") {
+ toast.error(
+ outcome.reason === "invalid_token"
+ ? tErrors("400.title")
+ : tErrors("title"),
+ {
+ description:
+ outcome.reason === "invalid_token"
+ ? tErrors("400.desc")
+ : tErrors("internal_server_error"),
+ },
+ );
+
+ return;
+ }
+
+ toast.success(t("success.title"), { description: t("success.desc") });
+ onChanged();
+ };
+
+ return { formSchema, onSubmit };
+};
diff --git a/packages/vitnode/src/views/auth/password-reset/change-password-form/use-form.ts b/packages/vitnode/src/views/auth/password-reset/change-password-form/use-form.ts
deleted file mode 100644
index d6febd7df..000000000
--- a/packages/vitnode/src/views/auth/password-reset/change-password-form/use-form.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useTranslations } from "next-intl";
-import { toast } from "sonner";
-import z from "zod";
-
-import { useRouter } from "@/lib/navigation";
-
-import type { ChangePasswordForm } from "./form";
-
-import { usePasswordZodSchema } from "../../sign-up/form/use-form";
-import { mutationApi } from "./mutation-api.server";
-
-export const useForm = ({
- token,
- userId,
-}: React.ComponentProps) => {
- const t = useTranslations("core.auth.change_password");
- const tError = useTranslations("core.global.errors");
- const passwordSchema = usePasswordZodSchema();
- const { replace } = useRouter();
-
- const formSchema = z.object({
- password: passwordSchema,
- });
-
- const onSubmit = async (data: z.infer) => {
- const mutation = await mutationApi({
- password: data.password,
- token,
- userId: +userId,
- });
-
- if (mutation?.error) {
- toast.error(tError("title"), {
- description: tError("internal_server_error"),
- });
-
- return;
- }
-
- toast.success(t("success.title"), {
- description: t("success.desc"),
- });
- replace("/login");
- };
-
- return {
- formSchema,
- onSubmit,
- };
-};
diff --git a/packages/vitnode/src/views/auth/password-reset/form/form.tsx b/packages/vitnode/src/views/auth/password-reset/form/form.tsx
index d9c23f9c2..87a0a5528 100644
--- a/packages/vitnode/src/views/auth/password-reset/form/form.tsx
+++ b/packages/vitnode/src/views/auth/password-reset/form/form.tsx
@@ -2,96 +2,22 @@
import type z from "zod";
-import { MailCheckIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
-
import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
-import { AutoForm } from "@/components/form/auto-form";
-import { AutoFormInput } from "@/components/form/fields/input";
-import {
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-
-import { useForm } from "./use-form";
-
-function ConfirmationView({ email }: { email: string }) {
- const t = useTranslations("core.auth.reset_password");
- const tSignUp = useTranslations("core.auth.sign_up");
-
- return (
- <>
-
-
-
-
-
- {t("confirmation.title")}
-
-
- {t("confirmation.desc")}
-
-
-
-
-
-
-
-
-
- {t("confirmation.check_spam")}
-
- >
- );
-}
+import { mutationApi } from "./mutation-api.server";
+import { PasswordResetFormContent } from "./password-reset-form-content";
+/**
+ * {@link PasswordResetFormContent}, wired to Next.js.
+ *
+ * One prop wide, and that prop is the whole of the boundary: a server action
+ * that asks the API to send a reset link. Nothing about the screen changes with
+ * the framework, so nothing else is passed.
+ */
export const PasswordResetForm = ({
captcha,
}: {
captcha: z.infer["captcha"];
-}) => {
- const { formSchema, onSubmit, sentEmail } = useForm();
- const t = useTranslations("core.auth.reset_password");
- const tSignUp = useTranslations("core.auth.sign_up");
-
- if (sentEmail) {
- return ;
- }
-
- return (
- <>
-
-
- {t("title")}
-
- {t("desc")}
-
-
-
- (
-
- ),
- },
- ]}
- formSchema={formSchema}
- onSubmit={onSubmit}
- submitButtonProps={{
- className: "w-full",
- children: t("submit"),
- }}
- />
-
- >
- );
-};
+}) => (
+
+);
diff --git a/packages/vitnode/src/views/auth/password-reset/form/mutation-api.server.ts b/packages/vitnode/src/views/auth/password-reset/form/mutation-api.server.ts
index 407e81233..445f5a654 100644
--- a/packages/vitnode/src/views/auth/password-reset/form/mutation-api.server.ts
+++ b/packages/vitnode/src/views/auth/password-reset/form/mutation-api.server.ts
@@ -3,13 +3,27 @@
import { usersModule } from "@/api/modules/users/users.module";
import { fetcher } from "@/lib/fetcher";
+import type {
+ PasswordResetMutationResult,
+ PasswordResetSubmitValues,
+} from "./schema";
+
+/**
+ * Asking the API for a reset link, for Next.js.
+ *
+ * `201` is the only success the route declares, and it is what a *good* request
+ * gets whether or not the address belongs to an account - the API decides that
+ * on its own side and says nothing about it. So there is nothing to inspect
+ * here beyond "did it get through", and nothing this layer could reveal even if
+ * it wanted to.
+ *
+ * No `allowSaveCookies`: this route mints no session, and copying whatever
+ * cookies a reply happened to carry is not something to do by default.
+ */
export const mutationApi = async ({
- email,
captchaToken,
-}: {
- captchaToken: string;
- email: string;
-}) => {
+ email,
+}: PasswordResetSubmitValues): Promise => {
const res = await fetcher(usersModule, {
module: "users",
path: "/reset-password",
@@ -20,7 +34,7 @@ export const mutationApi = async ({
},
});
- if (res.status !== 201) {
- return { error: "internal_server_error" };
- }
+ if (res.status !== 201) return { message: "Internal Server Error" };
+
+ return undefined;
};
diff --git a/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx
new file mode 100644
index 000000000..ff328774e
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import type z from "zod";
+
+import { MailCheckIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
+
+import { AutoForm } from "@/components/form/auto-form";
+import { AutoFormInput } from "@/components/form/fields/input";
+import {
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+import {
+ type PasswordResetSubmit,
+ usePasswordResetForm,
+} from "./use-password-reset-form";
+
+export type { PasswordResetSubmit };
+
+/**
+ * "We have sent you a link", and the address it went to.
+ *
+ * Shown for every accepted request, including one for an address with no
+ * account: the API answers `201` either way, so this screen is the only thing a
+ * visitor - or somebody probing for registered addresses - ever sees.
+ */
+const ConfirmationView = ({ email }: { email: string }) => {
+ const t = useTranslations("core.auth.reset_password");
+ const tSignUp = useTranslations("core.auth.sign_up");
+
+ return (
+ <>
+
+
+
+
+
+ {t("confirmation.title")}
+
+
+ {t("confirmation.desc")}
+
+
+
+
+
+
+
+
+
+ {t("confirmation.check_spam")}
+
+ >
+ );
+};
+
+/**
+ * The first half of password recovery - shared.
+ *
+ * One field, one captcha and one callback: {@link PasswordResetSubmit} is the
+ * only framework-specific part, and it is a prop. The form no longer imports a
+ * server action, so a TanStack Start route renders exactly the card the Next.js
+ * page renders.
+ */
+export const PasswordResetFormContent = ({
+ captcha,
+ onRequestReset,
+}: {
+ captcha: z.infer["captcha"];
+ onRequestReset: PasswordResetSubmit;
+}) => {
+ const { formSchema, onSubmit, sentEmail } = usePasswordResetForm({
+ onRequestReset,
+ });
+ const t = useTranslations("core.auth.reset_password");
+ const tSignUp = useTranslations("core.auth.sign_up");
+
+ if (sentEmail) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ {t("title")}
+
+ {t("desc")}
+
+
+
+ (
+
+ ),
+ },
+ ]}
+ formSchema={formSchema}
+ onSubmit={onSubmit}
+ submitButtonProps={{
+ className: "w-full",
+ children: t("submit"),
+ }}
+ />
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/views/auth/password-reset/form/schema.test.ts b/packages/vitnode/src/views/auth/password-reset/form/schema.test.ts
new file mode 100644
index 000000000..fa2415ba9
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/form/schema.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createPasswordResetFormSchema,
+ passwordResetFormOutcome,
+} from "./schema";
+
+const schema = createPasswordResetFormSchema({ invalidEmail: "not an email" });
+
+describe("the reset-request schema", () => {
+ it("accepts an email address", () => {
+ expect(schema.parse({ email: "test@test.com" })).toEqual({
+ email: "test@test.com",
+ });
+ });
+
+ it("rejects a value that is not an email address, with the message it was given", () => {
+ const parsed = schema.safeParse({ email: "test" });
+
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.message).toBe("not an email");
+ });
+
+ it("defaults the field, so AutoForm renders a controlled input", () => {
+ expect(schema.shape.email.def.defaultValue).toBe("");
+ });
+});
+
+describe("what a submit result means for the screen", () => {
+ it("shows the confirmation screen for an accepted request", () => {
+ expect(passwordResetFormOutcome(undefined)).toEqual({
+ kind: "confirmation",
+ });
+ });
+
+ it("has no outcome that could mean the address does not exist", () => {
+ // The anti-enumeration property, stated as a test: the API answers the same
+ // 201 either way, so the only two outcomes are "accepted" and "the request
+ // failed". A third would be a leak.
+ expect(passwordResetFormOutcome(undefined).kind).toBe("confirmation");
+ expect(
+ passwordResetFormOutcome({ message: "Internal Server Error" }).kind,
+ ).toBe("toast");
+ });
+});
diff --git a/packages/vitnode/src/views/auth/password-reset/form/schema.ts b/packages/vitnode/src/views/auth/password-reset/form/schema.ts
new file mode 100644
index 000000000..9374424fa
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/form/schema.ts
@@ -0,0 +1,66 @@
+import { z } from "zod";
+
+/**
+ * The "send me a reset link" form's shape and its failure vocabulary, with no
+ * React in sight.
+ *
+ * One field and two outcomes, so this is a small module - but it is the same
+ * split the sign-in and sign-up forms make, and it is what lets the interesting
+ * half be checked without a renderer or a request.
+ */
+
+export interface PasswordResetFormMessages {
+ /** Shown when the email field is not an email address. */
+ invalidEmail: string;
+}
+
+export const createPasswordResetFormSchema = ({
+ invalidEmail,
+}: PasswordResetFormMessages) =>
+ z.object({
+ email: z.email({ message: invalidEmail }).default(""),
+ });
+
+export type PasswordResetFormSchema = ReturnType<
+ typeof createPasswordResetFormSchema
+>;
+export type PasswordResetFormValues = z.infer;
+
+/** What the form sends: the address, and the captcha the route requires. */
+export interface PasswordResetSubmitValues {
+ captchaToken: string;
+ email: string;
+}
+
+/**
+ * What the API told us about a reset request.
+ *
+ * `undefined` means accepted - and *only* that. The API deliberately answers
+ * `201` whether or not the address belongs to an account, and whether or not it
+ * decided to skip the send because one was already requested in the last five
+ * minutes (`users/routes/reset-passowrd.route.ts`). That is the product's
+ * anti-enumeration behaviour, so this type has no shape in which "no such
+ * account" could be expressed: there is nothing to report but "we have taken
+ * your request".
+ *
+ * `{ message: 'Internal Server Error' }` is a request that did not reach that
+ * point at all - the transport failed, the rate limiter refused it, the API
+ * errored - and the screen raises the internal-error toast rather than claiming
+ * an email is on its way.
+ */
+export type PasswordResetMutationResult =
+ undefined | { message: "Internal Server Error" };
+
+/**
+ * What a submit result means for the screen.
+ *
+ * - `"confirmation"` - swap the card for "check your email", printing the
+ * address the visitor typed. Reached for *every* accepted request, which is
+ * exactly why it reveals nothing.
+ * - `"toast"` - the internal-error toast; the form stays as it is so the visitor
+ * can try again.
+ */
+export const passwordResetFormOutcome = (
+ result: PasswordResetMutationResult,
+): { kind: "confirmation" } | { kind: "toast" } =>
+ result?.message ? { kind: "toast" } : { kind: "confirmation" };
diff --git a/packages/vitnode/src/views/auth/password-reset/form/use-form.ts b/packages/vitnode/src/views/auth/password-reset/form/use-form.ts
deleted file mode 100644
index 599886c1e..000000000
--- a/packages/vitnode/src/views/auth/password-reset/form/use-form.ts
+++ /dev/null
@@ -1,41 +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 useForm = () => {
- const t = useTranslations("core.auth.sign_up");
- const tError = useTranslations("core.global.errors");
- const [sentEmail, setSentEmail] = React.useState("");
-
- const formSchema = z.object({
- email: z.email({ message: t("email.invalid") }).default(""),
- });
-
- const onSubmit: AutoFormOnSubmit = async (
- data,
- _form,
- { captchaToken },
- ) => {
- const mutation = await mutationApi({ email: data.email, captchaToken });
- if (mutation?.error) {
- toast.error(tError("title"), {
- description: tError("internal_server_error"),
- });
-
- return;
- }
-
- setSentEmail(data.email);
- };
-
- return {
- formSchema,
- onSubmit,
- sentEmail,
- };
-};
diff --git a/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts b/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts
new file mode 100644
index 000000000..79831e2d4
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts
@@ -0,0 +1,78 @@
+"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 {
+ PasswordResetFormSchema,
+ PasswordResetMutationResult,
+ PasswordResetSubmitValues,
+} from "./schema";
+
+import {
+ createPasswordResetFormSchema,
+ passwordResetFormOutcome,
+} from "./schema";
+
+export type { PasswordResetSubmitValues };
+
+/**
+ * How the form asks for a reset link.
+ *
+ * The whole of the framework boundary for password recovery's first half: an
+ * address and a captcha token in, "it was accepted" or "it failed" out. Next.js
+ * calls a server action; TanStack Start calls a server function. Neither is
+ * imported here.
+ */
+export type PasswordResetSubmit = (
+ values: PasswordResetSubmitValues,
+) => Promise;
+
+/**
+ * The reset-request form's behaviour, with no idea which framework is rendering
+ * it.
+ *
+ * `sentEmail` is the whole of its state, and it is local on purpose: the
+ * confirmation screen prints the address the visitor typed, which this side
+ * already has, so nothing needs to come back from the server for it. Which is
+ * also what makes the screen say the same thing for an address that exists and
+ * one that does not.
+ */
+export const usePasswordResetForm = ({
+ onRequestReset,
+}: {
+ onRequestReset: PasswordResetSubmit;
+}) => {
+ const t = useTranslations("core.auth.sign_up");
+ const tErrors = useTranslations("core.global.errors");
+ const [sentEmail, setSentEmail] = React.useState("");
+
+ const formSchema = createPasswordResetFormSchema({
+ invalidEmail: t("email.invalid"),
+ });
+
+ const onSubmit: AutoFormOnSubmit = async (
+ { email },
+ _form,
+ { captchaToken },
+ ) => {
+ const outcome = passwordResetFormOutcome(
+ await onRequestReset({ captchaToken, email }),
+ );
+
+ if (outcome.kind === "toast") {
+ toast.error(tErrors("title"), {
+ description: tErrors("internal_server_error"),
+ });
+
+ return;
+ }
+
+ setSentEmail(email);
+ };
+
+ return { formSchema, onSubmit, sentEmail };
+};
diff --git a/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx b/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx
new file mode 100644
index 000000000..0fdb45d47
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx
@@ -0,0 +1,39 @@
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * The card both recovery screens live in - shared.
+ *
+ * Thin on purpose: the two forms render their own `CardHeader` and
+ * `CardContent`, so all this owns is the page's measure and the card around it.
+ * It exists so that "the reset-password page" is one layout rather than two that
+ * have to be kept looking alike, in the same way `SignInContent` is.
+ *
+ * Not a client component. It has no hooks and no strings, which lets the Next.js
+ * page keep rendering it on the server with its `` boundary inside.
+ */
+export const PasswordResetContent = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => (
+
+ {children}
+
+);
+
+/** Either form's shape while the deployment configuration is still in flight. */
+export const PasswordResetSkeleton = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+);
diff --git a/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx b/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx
index 116b01fac..0aece5509 100644
--- a/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx
+++ b/packages/vitnode/src/views/auth/password-reset/password-reset-view.tsx
@@ -6,30 +6,42 @@ import React from "react";
import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
import { I18nProvider } from "@/components/i18n-provider";
-import { Card, CardContent, CardHeader } from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
import { getMiddlewareApi } from "@/lib/api/get-middleware-api";
import { ChangePasswordForm } from "./change-password-form/form";
import { PasswordResetForm } from "./form/form";
+import {
+ PasswordResetContent,
+ PasswordResetSkeleton,
+} from "./password-reset-content";
+import { parseRecoveryLink } from "./recovery-link";
type Captcha = z.infer["captcha"];
-const PasswordResetContent = async ({
+/**
+ * Which of the two recovery screens this URL asks for.
+ *
+ * `parseRecoveryLink` rather than `if (token && userId)`: the query comes out of
+ * an email and anyone can craft one, so a `?token=%20&userId=0` must render the
+ * request form rather than a change-password form that can only fail. The rule
+ * is shared with the TanStack Start route, which reads the same parameters
+ * through its own search schema.
+ */
+const PasswordResetRouteContent = async ({
captcha,
searchParams,
}: {
captcha: Captcha;
- searchParams: Promise<{ token: string; userId: string }>;
+ searchParams: Promise<{ token?: string; userId?: string }>;
}) => {
- const { token, userId } = await searchParams;
+ const link = parseRecoveryLink(await searchParams);
- if (token && userId) {
+ if (link) {
return (
-
+
);
}
@@ -43,36 +55,22 @@ const PasswordResetContent = async ({
);
};
-const PasswordResetContentSkeleton = () => (
- <>
-
-
-
-
-
-
-
-
-
-
- >
-);
-
export const PasswordResetView = async ({
searchParams,
}: {
- searchParams: Promise<{ token: string; userId: string }>;
+ searchParams: Promise<{ token?: string; userId?: string }>;
}) => {
- const { isEmail, captcha } = await getMiddlewareApi();
+ const { captcha, isEmail } = await getMiddlewareApi();
if (!isEmail) notFound();
return (
-
-
- }>
-
-
-
-
+
+ }>
+
+
+
);
};
diff --git a/packages/vitnode/src/views/auth/password-reset/recovery-link.test.ts b/packages/vitnode/src/views/auth/password-reset/recovery-link.test.ts
new file mode 100644
index 000000000..7fde8f60b
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/recovery-link.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+
+import { parseRecoveryLink } from "./recovery-link";
+
+/** What the API actually puts in the email: 32 random bytes as base64url. */
+const TOKEN = "PSyRy0nQ0hRnfx3iCYldQ40mBLU9lqfDWtvNhrTsJI4";
+
+describe("parsing a recovery link", () => {
+ it("accepts what the reset email builds", () => {
+ expect(parseRecoveryLink({ token: TOKEN, userId: "123" })).toEqual({
+ token: TOKEN,
+ userId: 123,
+ });
+ });
+
+ it("accepts a userId that is already a number", () => {
+ // A TanStack Start route's `validateSearch` may well have coerced it before
+ // this sees it; the Next.js view hands over the raw string.
+ expect(parseRecoveryLink({ token: TOKEN, userId: 123 })).toEqual({
+ token: TOKEN,
+ userId: 123,
+ });
+ });
+
+ it.each([
+ ["nothing at all", {}],
+ ["a token with no account", { token: TOKEN }],
+ ["an account with no token", { userId: "123" }],
+ ])("answers null for %s, so the request form is shown", (_case, input) => {
+ expect(parseRecoveryLink(input)).toBeNull();
+ });
+
+ it.each([
+ ["an empty userId", ""],
+ ["a zero userId", "0"],
+ ["a negative userId", "-1"],
+ ["a fractional userId", "1.5"],
+ ["a signed userId", "+1"],
+ ["a padded userId", " 1"],
+ ["an exponent", "1e3"],
+ ["hexadecimal", "0x10"],
+ ["a word", "abc"],
+ ["a boolean", true],
+ ["a list", ["1", "2"]],
+ ["null", null],
+ ["past the safe integer range", "9007199254740993"],
+ ])("rejects %s rather than coercing it", (_case, userId) => {
+ // `Number("")` is 0 and `Number(true)` is 1, which is exactly why the digits
+ // are checked before the coercion rather than after.
+ expect(parseRecoveryLink({ token: TOKEN, userId })).toBeNull();
+ });
+
+ it.each([
+ ["an empty token", ""],
+ ["a whitespace token", " "],
+ ["a token that is too short to be one", "abc"],
+ ["a path traversal attempt", `../../${TOKEN}`],
+ ["a token carrying a newline", `${TOKEN}\n`],
+ ["a token carrying a space", `${TOKEN} x`],
+ ["a token with a percent escape", `${TOKEN}%2F`],
+ ["an unbounded token", "a".repeat(513)],
+ ["a non-string token", 123],
+ ])("rejects %s", (_case, token) => {
+ expect(parseRecoveryLink({ token, userId: "123" })).toBeNull();
+ });
+
+ it("keeps the token exactly as it arrived", () => {
+ // The API compares it byte for byte against the stored row, so any
+ // normalisation here would break every real link.
+ const link = parseRecoveryLink({ token: TOKEN, userId: "1" });
+
+ expect(link?.token).toBe(TOKEN);
+ });
+});
diff --git a/packages/vitnode/src/views/auth/password-reset/recovery-link.ts b/packages/vitnode/src/views/auth/password-reset/recovery-link.ts
new file mode 100644
index 000000000..9e653cfe9
--- /dev/null
+++ b/packages/vitnode/src/views/auth/password-reset/recovery-link.ts
@@ -0,0 +1,83 @@
+import { z } from "zod";
+
+/**
+ * The two values a password-recovery email puts in the URL, judged before
+ * anything is done with them.
+ *
+ * `/login/reset-password?token=...&userId=...` is a link in an email, which means
+ * the query is the least trustworthy input on the recovery screens: anyone can
+ * craft one, and the page decides *which form to render* from whether both
+ * values are present. So the rule is a schema rather than a truthiness check,
+ * and it lives here - pure, framework-free, with no React and no fetcher - so
+ * both the Next.js view and a TanStack Start route reach the same verdict from
+ * the same code.
+ *
+ * ## What it is not
+ *
+ * Not authentication. The API is the boundary and stays the boundary: it looks
+ * the row up by `userId` *and* `token` *and* an unexpired `expiresAt`, and
+ * answers `400 Invalid token` when any of the three does not match
+ * (`users/routes/change-password.route.ts`). Nothing here can grant a password
+ * change; it only decides whether a request is worth making at all, and stops a
+ * crafted URL from turning into a request carrying an unbounded string or a
+ * `userId` the API would have to coerce.
+ */
+
+/**
+ * The recovery token, as it may appear in a URL.
+ *
+ * The API generates it as `randomBytes(32).toString("base64url")` - 43
+ * characters of `[A-Za-z0-9_-]` - so the character class is a true statement
+ * about the value rather than a guess, and it is what excludes whitespace,
+ * control characters and path separators. The length bounds are deliberately
+ * loose around the real 43 so a change to the API's token generation widens
+ * rather than breaks this.
+ */
+const recoveryTokenSchema = z
+ .string()
+ .min(16)
+ .max(512)
+ .regex(/^[A-Za-z0-9_-]+$/);
+
+/**
+ * The account the link belongs to.
+ *
+ * A query parameter arrives as a string, and `Number("")` is `0` while
+ * `Number(true)` is `1` - so the digits are checked *before* the coercion rather
+ * than after, and only a string of digits or an actual number is accepted. The
+ * cap is `Number.MAX_SAFE_INTEGER` because past it two different ids compare
+ * equal, which is not a value to send to a lookup.
+ */
+const recoveryUserIdSchema = z
+ .union([z.number(), z.string().regex(/^\d+$/)])
+ .transform(value => Number(value))
+ .pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER));
+
+export const recoveryLinkSchema = z.object({
+ token: recoveryTokenSchema,
+ userId: recoveryUserIdSchema,
+});
+
+/** A recovery link this app is willing to act on. */
+export type RecoveryLink = z.infer;
+
+/**
+ * The link's two values, normalised, or `null`.
+ *
+ * `null` is the answer for every unusable shape - missing, empty, malformed, out
+ * of range - because the screens have exactly one thing to do about all of them:
+ * render the "request a reset link" form instead of the "choose a new password"
+ * one. Which is what the Next.js view already does with `if (token && userId)`,
+ * only spelled as a rule that a crafted `?token=%20&userId=0` cannot walk past.
+ */
+export const parseRecoveryLink = (input: {
+ token?: unknown;
+ userId?: unknown;
+}): null | RecoveryLink => {
+ const parsed = recoveryLinkSchema.safeParse({
+ token: input.token,
+ userId: input.userId,
+ });
+
+ return parsed.success ? parsed.data : null;
+};
diff --git a/packages/vitnode/src/views/auth/settings/devices/device-item.tsx b/packages/vitnode/src/views/auth/settings/devices/device-item.tsx
index 16f97bcba..c9afe64a4 100644
--- a/packages/vitnode/src/views/auth/settings/devices/device-item.tsx
+++ b/packages/vitnode/src/views/auth/settings/devices/device-item.tsx
@@ -1,15 +1,17 @@
-import { MonitorIcon, SmartphoneIcon, TabletIcon } from "lucide-react";
-import { getTranslations } from "next-intl/server";
+"use client";
-import type { DevicesApi } from "@/lib/api/get-devices-api";
+import { MonitorIcon, SmartphoneIcon, TabletIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
import { DateFormat } from "@/components/date-format";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
-import { RevokeDeviceButton } from "./revoke-device-button";
+import type { Device } from "./devices-query";
+import type { RevokeDevice } from "./devices-revoke";
-type Device = DevicesApi["devices"][number];
+import { isRevokableDevice } from "./devices-revoke";
+import { RevokeDeviceButton } from "./revoke-device-button";
const icons = {
desktop: MonitorIcon,
@@ -17,25 +19,36 @@ const icons = {
tablet: TabletIcon,
} as const;
-export const DeviceItem = async ({
- browser,
- deviceType,
- expiresAt,
- ipAddress,
- isCurrent,
- lastSeen,
- os,
- publicId,
-}: Device) => {
- const t = await getTranslations("core.auth.settings.devices");
- const Icon = icons[deviceType];
+/**
+ * One device, as a card both frameworks render.
+ *
+ * Everything that used to make this a Next.js Server Component has been taken
+ * out: it no longer awaits `getTranslations`, and the revoke it offers arrives as
+ * a prop instead of being imported. What is left is the part that was always
+ * worth sharing - the icon, the current-device badge, the relative last-seen
+ * date, the three details and the layout of all of it.
+ *
+ * The row is handed over whole rather than spread as eight props, which is what
+ * lets `isRevokableDevice` read it: the rule about the current device is one
+ * statement in `devices-revoke.ts` and this is the only place it is applied to a
+ * button.
+ */
+export const DeviceItem = ({
+ device,
+ onRevoke,
+}: {
+ device: Device;
+ onRevoke: RevokeDevice;
+}) => {
+ const t = useTranslations("core.auth.settings.devices");
+ const Icon = icons[device.deviceType];
const details = [
- { label: t("browser"), value: browser },
- { label: t("ip_address"), value: ipAddress },
+ { label: t("browser"), value: device.browser },
+ { label: t("ip_address"), value: device.ipAddress },
{
label: t("session_expires"),
- value: ,
+ value: ,
},
];
@@ -48,15 +61,27 @@ export const DeviceItem = async ({
- {os}
- {isCurrent && {t("current_device")} }
+ {device.os}
+ {device.isCurrent && {t("current_device")} }
- {t("last_active")}:
+ {t("last_active")}:
- {!isCurrent && }
+ {/*
+ No button on the current device, because the API refuses to revoke it -
+ `DELETE /users/devices/{publicId}` answers 400 for the id matching the
+ requester's own device cookie. Offering it would put a refusal behind a
+ button whose only outcome is an error toast.
+ */}
+ {isRevokableDevice(device) && (
+
+ )}
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts b/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts
new file mode 100644
index 000000000..20b95f79b
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts
@@ -0,0 +1,257 @@
+// @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, "../../../..");
+
+/**
+ * `/settings/devices`, split down the middle.
+ *
+ * The same boundary `files-boundaries.test.ts` and `auth-boundaries.test.ts`
+ * draw, with the same machinery and for the same reason: a shared module that
+ * reaches `next/headers`, a server action or `@/lib/navigation` cannot be loaded
+ * by a TanStack Start route, and nothing about that failure is visible until
+ * somebody tries. A scan is the only way to state it, because the offending
+ * import is usually two files away from the one being written - this feature's
+ * would have been the server action, imported by the revoke button, behind the
+ * list.
+ */
+const SHARED = {
+ item: join(here, "device-item.tsx"),
+ list: join(here, "devices-content.tsx"),
+ query: join(here, "devices-query.ts"),
+ revoke: join(here, "devices-revoke.ts"),
+ revokeButton: join(here, "revoke-device-button.tsx"),
+ skeleton: join(here, "devices-list-skeleton.tsx"),
+};
+
+/** The Next.js half: `next/navigation`, `next/cache`, `fetcher()`, the action. */
+const NEXT_WRAPPERS = {
+ list: join(here, "devices-list.tsx"),
+ page: join(here, "devices.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: the query module imports the users
+ * API module's *type* to keep the fetcher's route literals inferring, and that
+ * module is a Hono server module. It is erased at compile time and never reaches
+ * a bundle, so counting it would fail this suite on something that cannot break.
+ */
+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 -
+ * `ConfirmActionAlertDialog`, which is what the revoke button's dialog is. 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,
+}));
+
+const wrapperEntries = Object.entries(NEXT_WRAPPERS).map(([name, path]) => ({
+ name,
+ path,
+}));
+
+describe("the import scan finds what it is looking for", () => {
+ // Most assertions below are "found nothing" ones, which a scanner that
+ // silently matches nothing also satisfies. The Next wrappers are the control:
+ // they provably import the things the shared modules must not.
+ it.each(wrapperEntries)(
+ "finds the Next-only imports in the $name wrapper",
+ ({ path }) => {
+ expect(offenders(path, NEXT_ONLY)).not.toEqual([]);
+ },
+ );
+
+ it("walks past the entry file into its dependencies", () => {
+ // `next/headers` is two hops from the list wrapper - through `@/lib/fetcher` -
+ // not one.
+ expect(offenders(NEXT_WRAPPERS.list, ["next/headers"]).join()).toContain(
+ "lib/fetcher.ts",
+ );
+ });
+});
+
+describe("the shared devices modules 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 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.
+ // The revoke 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,
+ );
+ });
+
+ it("never imports the API's own module for one plugin id", () => {
+ // The fetchers need the users module's *type* to keep route literals
+ // inferring; a value import would drag Hono, Drizzle and `@/database` into
+ // the browser bundle of every page that lists a device.
+ const reached = [...externalGraph(SHARED.query).keys()];
+
+ expect(reached).not.toContain("drizzle-orm");
+ expect(reached.some(one => one.startsWith("hono"))).toBe(false);
+ });
+});
+
+describe("the shared list takes its framework parts as props", () => {
+ const withoutComments = (path: string): string =>
+ readFileSync(path, "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/\/\/.*$/gm, "");
+
+ it("is handed the devices rather than fetching them", () => {
+ const code = withoutComments(SHARED.list);
+
+ expect(code).toContain("devices: Device[];");
+ expect(code).not.toContain("useQuery");
+ expect(code).not.toContain("fetcher");
+ });
+
+ it("is handed the revoke rather than importing one", () => {
+ const code = withoutComments(SHARED.list);
+
+ expect(code).toContain("onRevoke: RevokeDevice;");
+ });
+
+ it("passes the revoke down to the button rather than the button finding it", () => {
+ expect(withoutComments(SHARED.revokeButton)).toContain(
+ "onRevoke: RevokeDevice;",
+ );
+ });
+});
+
+describe("the Next wrapper keeps the Next-only pieces", () => {
+ it("is the only half that fetches and refuses", () => {
+ const code = readFileSync(NEXT_WRAPPERS.list, "utf8");
+
+ expect(code).toContain("notFound");
+ expect(runtimeImports(NEXT_WRAPPERS.list)).toContain("@/lib/fetcher");
+ });
+
+ it("builds its request from the shared contract rather than its own", () => {
+ // The point of the split: a list means the same thing in both apps because
+ // both call the same function, not because two places look alike.
+ expect(readFileSync(NEXT_WRAPPERS.list, "utf8")).toContain(
+ "devicesRequest",
+ );
+ });
+
+ it("is where the server action and its revalidate live", () => {
+ const action = readFileSync(join(here, "revoke-action.server.ts"), "utf8");
+
+ expect(action).toContain('"use server"');
+ expect(action).toContain("revalidatePath");
+ // ...and it applies the shared refresh rule rather than a second copy of it.
+ expect(action).toContain("shouldRefreshAfterRevoke");
+ expect(action).toContain("revokeDeviceRequest");
+ });
+});
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx b/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx
new file mode 100644
index 000000000..4b7517458
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-content.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useTranslations } from "use-intl";
+
+import type { Device } from "./devices-query";
+import type { RevokeDevice } from "./devices-revoke";
+
+import { DeviceItem } from "./device-item";
+
+/**
+ * The visitor's devices, as a list both frameworks render.
+ *
+ * The presentation half of `/settings/devices`, and the whole of it: the cards,
+ * the spacing between them, and the sentence that stands in for an empty list.
+ *
+ * Next.js devices-list.tsx fetch + notFound + server action
+ * TanStack Start routes/.../settings/devices loader + useSuspenseQuery + browser revoke
+ * \ /
+ * DevicesContent
+ *
+ * ## What it does not own
+ *
+ * **Fetching.** It is handed a list. Which list, and how it was fetched, is
+ * `devices-query.ts`'s - the same definition a TanStack loader warms and a
+ * Next.js Server Component awaits. That is also why an API failure never reaches
+ * here: it is a rejected query, not an empty array, so this component's "no
+ * devices" state means only that the API said so.
+ *
+ * **Revoking.** One callback, because the two frameworks genuinely differ: one
+ * ends in `revalidatePath`, the other in a query invalidation, and neither can
+ * exist in the other's runtime. The request, the status mapping and the rule
+ * about the current device are shared - see `devices-revoke.ts`.
+ *
+ * **The heading.** Deliberately outside, in each framework's own page. The
+ * Next.js page renders `HeaderContent` above a `` whose fallback is
+ * `DevicesListSkeleton`, so the title is on screen while the list is still
+ * streaming; folding the heading in here would put it behind the same boundary
+ * and lose that.
+ */
+export const DevicesContent = ({
+ devices,
+ onRevoke,
+}: {
+ devices: Device[];
+ onRevoke: RevokeDevice;
+}) => {
+ const t = useTranslations("core.auth.settings.devices");
+
+ if (devices.length === 0) {
+ return {t("empty")}
;
+ }
+
+ return (
+
+ {devices.map(device => (
+
+ ))}
+
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-list.tsx b/packages/vitnode/src/views/auth/settings/devices/devices-list.tsx
index 3a1e7c825..22362703c 100644
--- a/packages/vitnode/src/views/auth/settings/devices/devices-list.tsx
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-list.tsx
@@ -1,24 +1,46 @@
-import { getTranslations } from "next-intl/server";
+import { notFound } from "next/navigation";
-import { getDevicesApi } from "@/lib/api/get-devices-api";
+import { usersModule } from "@/api/modules/users/users.module";
+import { fetcher } from "@/lib/fetcher";
-import { DeviceItem } from "./device-item";
+import { DevicesContent } from "./devices-content";
+import { devicesRequest } from "./devices-query";
+import { revokeDeviceAction } from "./revoke-action.server";
+/**
+ * The Next.js half of `/settings/devices`: read the list, then hand it to the
+ * shared one.
+ *
+ * Everything Next.js about the feature is in this file. It is a Server
+ * Component, so it fetches with `fetcher()` - which reads the visitor's session
+ * and device cookies through `next/headers`, and the device cookie is what makes
+ * `isCurrent` correct - and answers a refusal with `notFound()`, which only
+ * exists here. The revoke callback is the server action, which ends in
+ * `revalidatePath`: the one step that cannot be shared.
+ *
+ * The request itself is *not* Next.js's. `devicesRequest()` is the same function
+ * the TanStack Start transport calls, so both applications ask the API for the
+ * same thing rather than in two places that merely look alike.
+ *
+ * ## A refused read is not an empty list
+ *
+ * This used to be `getDevicesApi()`, which called `res.json()` on whatever came
+ * back and handed the result straight to the list. A `401`, `403` or `429` body
+ * parses perfectly happily and has no `devices` in it, so the page either
+ * rendered "No active devices." or crashed reading `.length` of `undefined` -
+ * and the first of those is the most alarming thing this page can say, said about
+ * an outage. `notFound()` is the same answer `/files` gives to the same problem:
+ * a finite, honest "this page is not available", instead of a confident lie about
+ * the visitor's sessions.
+ */
export const DevicesList = async () => {
- const [t, { devices }] = await Promise.all([
- getTranslations("core.auth.settings.devices"),
- getDevicesApi(),
- ]);
+ const res = await fetcher(usersModule, devicesRequest());
- if (devices.length === 0) {
- return {t("empty")}
;
+ if (res.status !== 200) {
+ return notFound();
}
- return (
-
- {devices.map(device => (
-
- ))}
-
- );
+ const { devices } = await res.json();
+
+ return ;
};
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts
new file mode 100644
index 000000000..25baf1826
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts
@@ -0,0 +1,266 @@
+import { hashKey } from "@tanstack/react-query";
+import { describe, expect, it } from "vitest";
+
+import {
+ DEVICE_TYPES,
+ devicesQueryKey,
+ devicesQueryOptions,
+ devicesRequest,
+ DevicesRequestError,
+ isDevicesRequestError,
+} from "./devices-query";
+import {
+ isDevicePublicId,
+ isRevokableDevice,
+ REVOKE_CURRENT_DEVICE_STATUS,
+ revokeDeviceRequest,
+ revokeResultFromStatus,
+ shouldRefreshAfterRevoke,
+} from "./devices-revoke";
+
+/**
+ * The pure half of the devices contract.
+ *
+ * Everything below is a function over plain values: a request is built, a
+ * response status becomes either a list or an error, a revoke's status becomes a
+ * result, and a result becomes a yes-or-no about refreshing. Nothing here opens a
+ * socket or renders a component - the API has its own suite, and how the cards
+ * look is Playwright's.
+ */
+
+describe("the request the API is asked for", () => {
+ it("names the list route on the users module, with no parameters", () => {
+ // No parameters is the point: the route takes none and derives whose devices
+ // these are from the session cookie. A query string here would be a second
+ // source of truth for something the cookie already decides.
+ expect(devicesRequest()).toEqual({
+ method: "get",
+ module: "users",
+ path: "/devices",
+ });
+ });
+
+ it("addresses one device by its public id for a revoke", () => {
+ expect(revokeDeviceRequest({ publicId: "a1b2c3" })).toEqual({
+ args: { params: { publicId: "a1b2c3" } },
+ method: "delete",
+ module: "users",
+ path: "/devices/{publicId}",
+ });
+ });
+});
+
+describe("one list per visitor, one cache entry each", () => {
+ it("is keyed by the owner, under the devices domain", () => {
+ expect(devicesQueryKey(10)).toEqual(["devices", "user", 10]);
+ });
+
+ it("is the same entry however many times it is asked for", () => {
+ // The loader and the component both call the factory, and they have to land
+ // in the same entry or the loader fills one while the component reads the
+ // other.
+ expect(hashKey(devicesQueryOptions({ userId: 10 }).queryKey)).toBe(
+ hashKey(
+ devicesQueryOptions({
+ fetchDevices: async () => Promise.resolve({ devices: [] }),
+ userId: 10,
+ }).queryKey,
+ ),
+ );
+ });
+
+ /**
+ * The privacy invariant, as the key contract rather than as a browser test.
+ *
+ * The browser's `QueryClient` outlives a sign-out, so one document can hold
+ * two visitors. Under the `["devices", "me"]` this replaces, B's loader asked
+ * for the entry A had already filled - and with `refetchOnMount` off, nothing
+ * refetched it, so no request was made and Hono never saw the read it would
+ * have refused.
+ */
+ it("gives two visitors two entries, so one can never read the other's", () => {
+ expect(devicesQueryKey(10)).not.toEqual(devicesQueryKey(20));
+ expect(hashKey(devicesQueryKey(10))).not.toBe(hashKey(devicesQueryKey(20)));
+ });
+
+ it("is what a revoke invalidates, so one visitor's refresh is their own", () => {
+ // Query matches by prefix, and this key has no sub-keys - so it is both the
+ // entry and the family, and invalidating it cannot reach visitor 20.
+ expect(devicesQueryOptions({ userId: 10 }).queryKey).toEqual(
+ devicesQueryKey(10),
+ );
+ });
+
+ it("does not share a prefix with the session entry", () => {
+ // Query matches keys by prefix, so a revoke invalidating this key must not
+ // reach `['vitnode', 'session']` - the one entry a route guard reads.
+ expect(devicesQueryKey(10)[0]).not.toBe("vitnode");
+ });
+
+ it("asks once, because every failure it can have is worse when repeated", () => {
+ expect(devicesQueryOptions({ userId: 10 }).retry).toBe(false);
+ });
+});
+
+/**
+ * The other half of the same rule: the id is a cache address, not a claim.
+ *
+ * If it ever reached the wire it would stop being a cache key and become an
+ * access-control parameter supplied by the browser - so the request is asserted
+ * to be exactly what it was before the key gained an owner.
+ */
+describe("the owner never leaves the browser", () => {
+ it("sends no arguments at all on the list request", () => {
+ // Not "no user id" - no arguments whatsoever. There is nowhere for one to
+ // travel, which is a stronger statement than any absence check.
+ expect(devicesRequest()).not.toHaveProperty("args");
+ expect(Object.keys(devicesRequest()).sort()).toEqual([
+ "method",
+ "module",
+ "path",
+ ]);
+ });
+
+ it("sends the device's public id on a revoke and nothing else", () => {
+ const request = revokeDeviceRequest({ publicId: "a1b2c3" });
+
+ expect(request.args).toEqual({ params: { publicId: "a1b2c3" } });
+ expect(Object.keys(request.args.params)).toEqual(["publicId"]);
+ });
+});
+
+describe("a refused read is not an empty list", () => {
+ it.each([401, 403, 429, 500])(
+ "turns %i into an error rather than a list nobody is signed in on",
+ status => {
+ const error = new DevicesRequestError(status);
+
+ expect(error.status).toBe(status);
+ expect(isDevicesRequestError(error)).toBe(true);
+ // The bug this replaces: `getDevicesApi()` parsed the refusal body, which
+ // has no `devices` in it, and the page said "No active devices."
+ expect(error).not.toHaveProperty("devices");
+ },
+ );
+
+ it("says which status refused, in the message", () => {
+ expect(new DevicesRequestError(429).message).toContain("429");
+ });
+
+ it("is recognised across two copies of the class", () => {
+ // `@vitnode/core` is imported from `dist` by the apps and from `src` by these
+ // tests, so `instanceof` can answer `false` for a genuine one. The guard is
+ // `name`-based, and this is the shape that proves it.
+ const fromAnotherCopy = new Error("The devices API answered 401 ...");
+ fromAnotherCopy.name = "DevicesRequestError";
+
+ expect(isDevicesRequestError(fromAnotherCopy)).toBe(true);
+ });
+
+ it("is not fooled by an ordinary error", () => {
+ expect(isDevicesRequestError(new Error("nope"))).toBe(false);
+ expect(isDevicesRequestError({ status: 401 })).toBe(false);
+ expect(isDevicesRequestError(undefined)).toBe(false);
+ });
+});
+
+describe("the row shape the API promises", () => {
+ it("has exactly the three device types the icons cover", () => {
+ expect([...DEVICE_TYPES]).toEqual(["desktop", "tablet", "mobile"]);
+ });
+});
+
+describe("the current device is the one that cannot be signed out", () => {
+ it("offers no revoke for the session doing the asking", () => {
+ // The API answers 400 for it, so a button here would only ever produce an
+ // error toast.
+ expect(isRevokableDevice({ isCurrent: true })).toBe(false);
+ });
+
+ it("offers a revoke for every other device", () => {
+ expect(isRevokableDevice({ isCurrent: false })).toBe(true);
+ });
+
+ it("names the status the API refuses with", () => {
+ expect(REVOKE_CURRENT_DEVICE_STATUS).toBe(400);
+ });
+});
+
+describe("the public ids a revoke will send", () => {
+ it("accepts the 32 hex characters `DeviceModel` mints", () => {
+ expect(isDevicePublicId("0123456789abcdef0123456789abcdef")).toBe(true);
+ });
+
+ it("accepts a shorter url-safe token, for ids minted by an older scheme", () => {
+ expect(isDevicePublicId("a1b2c3")).toBe(true);
+ expect(isDevicePublicId("a_b-c")).toBe(true);
+ });
+
+ it("refuses an empty id, which would address the list route", () => {
+ // `/devices/` + `""` is `DELETE /devices`, which is a different route.
+ expect(isDevicePublicId("")).toBe(false);
+ });
+
+ it("refuses anything that would leave the path segment", () => {
+ expect(isDevicePublicId("../session")).toBe(false);
+ expect(isDevicePublicId("a/b")).toBe(false);
+ expect(isDevicePublicId("a.b")).toBe(false);
+ expect(isDevicePublicId("%2e%2e")).toBe(false);
+ expect(isDevicePublicId("a b")).toBe(false);
+ });
+
+ it("refuses an id longer than any real one", () => {
+ expect(isDevicePublicId("a".repeat(128))).toBe(true);
+ expect(isDevicePublicId("a".repeat(129))).toBe(false);
+ });
+});
+
+describe("what a revoke's status becomes", () => {
+ it("is done only for the 200 the route declares", () => {
+ expect(revokeResultFromStatus(200)).toEqual({ data: true });
+ });
+
+ it.each([400, 401, 403, 404, 429, 500])(
+ "carries %i back for the dialog to phrase",
+ status => {
+ expect(revokeResultFromStatus(status)).toEqual({ error: { status } });
+ },
+ );
+
+ it("never reports both an outcome and a refusal", () => {
+ expect(revokeResultFromStatus(200).error).toBeUndefined();
+ expect(revokeResultFromStatus(404).data).toBeUndefined();
+ });
+});
+
+describe("whether a finished revoke makes the list stale", () => {
+ it("refreshes when the device actually went", () => {
+ expect(shouldRefreshAfterRevoke({ data: true })).toBe(true);
+ });
+
+ it("refreshes when the row was already wrong", () => {
+ // 404: somebody revoked it first. 400: the list believed it was revokable and
+ // the API considers it current. Either way the screen disagrees with the
+ // server, and refetching is the repair.
+ expect(shouldRefreshAfterRevoke({ error: { status: 404 } })).toBe(true);
+ expect(
+ shouldRefreshAfterRevoke({
+ error: { status: REVOKE_CURRENT_DEVICE_STATUS },
+ }),
+ ).toBe(true);
+ });
+
+ it.each([401, 403, 429, 500, 503])(
+ "leaves the list alone after a %i, which deleted nothing",
+ status => {
+ // A 429 answered by immediately re-reading is the thing the limiter is
+ // asking the app to stop doing; a 401 answered by re-reading blanks the
+ // list the person is looking at.
+ expect(shouldRefreshAfterRevoke({ error: { status } })).toBe(false);
+ },
+ );
+
+ it("does not refresh on a result that says nothing", () => {
+ expect(shouldRefreshAfterRevoke({})).toBe(false);
+ });
+});
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts
new file mode 100644
index 000000000..79433cc87
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts
@@ -0,0 +1,282 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import type { usersModule } from "@/api/modules/users/users.module";
+
+import { CONFIG_PLUGIN } from "@/config";
+import { clientModule, fetcherClient } from "@/lib/fetcher-client";
+
+/**
+ * The devices the signed-in visitor is logged in on, as one query definition.
+ *
+ * Everything about *what* that list is lives here and nowhere else: the request,
+ * the shape that comes back, what counts as a refusal, and the cache entry the
+ * whole thing lands in. A view renders whatever this produces and owns none of
+ * it.
+ *
+ * The split is the one `my-files-query.ts` already paid for. When a component
+ * built one request and a loader built another, the two agreed on the cache key
+ * and on nothing else - so the server-rendered page came from one contract and
+ * every navigation after hydration came from a second one with different
+ * defaults and no status checking. Sharing a key is not sharing a contract.
+ *
+ * The one thing deliberately *not* fixed here is the transport: a loader running
+ * on a server and a component running in a browser cannot reach the API the same
+ * way. So {@link devicesQueryOptions} takes a `fetchDevices` and defaults it to
+ * the browser's, which is the only one a shared module can assume.
+ *
+ * ## Hono is still the boundary
+ *
+ * Nothing below authorizes anything. `GET /api/@vitnode/core/users/devices`
+ * derives the user from the session cookie, scopes the query to their sessions,
+ * and marks the row matching the device cookie as `isCurrent` - so a request
+ * this module builds for a visitor who has just been signed out comes back `401`,
+ * and {@link DevicesRequestError} is what makes that a failed query rather than
+ * an empty list.
+ */
+
+/**
+ * The users module as a value the fetchers can carry without pulling the API
+ * into either bundle. The module is imported as a *type* only, so route
+ * literals, methods and response schemas all still infer; `clientModule`
+ * supplies the one field the fetcher reads at runtime.
+ */
+export const usersModuleRef = clientModule(
+ CONFIG_PLUGIN.pluginId,
+);
+
+/** Which icon a row gets, and the only three values the API will send. */
+export const DEVICE_TYPES = ["desktop", "tablet", "mobile"] as const;
+export type DeviceType = (typeof DEVICE_TYPES)[number];
+
+/**
+ * One row of the list, as JSON delivers it.
+ *
+ * `expiresAt` and `lastSeen` are declared as `Date | string` because both are
+ * true: the route's schema says `z.date()` and a Next.js Server Component that
+ * awaited the fetcher is handed exactly that, while anything that crossed the
+ * wire as JSON - the browser fetch, and the dehydrated SSR payload a TanStack
+ * Start page rehydrates - has an ISO string. `DateFormat` accepts either, which
+ * is why this is a widened type rather than a normalisation step.
+ */
+export interface Device {
+ browser: string;
+ deviceType: DeviceType;
+ expiresAt: Date | string;
+ ipAddress: string;
+ /**
+ * Whether this row is the session doing the asking.
+ *
+ * The API decides it, by comparing each row's `publicId` to the device cookie
+ * on the request - so it is a property of *this* request rather than of the
+ * device, and it is the reason the cookie has to reach the API on both
+ * transports. A render that forwarded no cookie would mark every row
+ * `isCurrent: false` and offer to revoke the session doing the rendering.
+ *
+ * `DELETE /users/devices/{publicId}` refuses that with a `400` regardless, so
+ * this flag is what the list uses to not offer the button - not the rule
+ * itself. See {@link isRevokableDevice}.
+ */
+ isCurrent: boolean;
+ lastSeen: Date | string;
+ os: string;
+ publicId: string;
+}
+
+/** The list route's whole response. */
+export interface DevicesApi {
+ devices: Device[];
+}
+
+/**
+ * The list, as arguments to whichever fetcher is carrying it.
+ *
+ * No parameters at all: the route takes none, and derives whose devices these
+ * are from the session cookie.
+ *
+ * Worth reading against {@link devicesQueryKey}, which *does* carry a user id.
+ * The two are not in tension - the key says which cache slot an answer is filed
+ * under, this says what is asked for, and only the cookie says whose devices
+ * come back. Adding an owner here would move authorization onto a value the
+ * browser supplies.
+ */
+export const devicesRequest = () =>
+ ({
+ method: "get" as const,
+ module: "users" as const,
+ path: "/devices" as const,
+ }) as const;
+
+/** How the list is actually fetched. See {@link devicesQueryOptions}. */
+export type DevicesFetcher = () => Promise;
+
+/** The `name` every {@link DevicesRequestError} carries. See below. */
+const DEVICES_REQUEST_ERROR = "DevicesRequestError";
+
+/**
+ * The devices API refused, and this is what it refused with.
+ *
+ * A thrown error rather than a returned one, because the alternative is the bug
+ * this class exists to prevent. `getDevicesApi()` - the module this replaces -
+ * called `res.json()` on whatever came back, and a `401`, `403` or `429` body
+ * parses perfectly happily; read as a list it has no `devices`, so the page
+ * rendered "No active devices." A visitor whose session had just ended, or who
+ * had tripped the rate limiter, was told they were signed in nowhere - which is
+ * the single most alarming thing this page can say, and it was saying it about
+ * an outage.
+ *
+ * `status` is on the error rather than folded into the message so a caller can
+ * tell the finite cases apart without parsing English: `401` and `403` mean the
+ * session ended or was never allowed - the route guard is a navigation rule, not
+ * the boundary, so this is the *authorization* answer and it can arrive on a
+ * page the guard already let through. `429` is the rate limiter. A `500` never
+ * reaches here at all: `rawApiFetch` throws on those with the body attached.
+ *
+ * Deliberately *not* a redirect to the login page. A failed read is not a
+ * signed-out visitor - the same rule `#/lib/session` states at length - and the
+ * guard on the route already owns that decision from the one canonical session
+ * entry. Turning every API failure into a sign-out is how a rate limit becomes a
+ * logout.
+ *
+ * Recognised by `name` rather than by `instanceof`, and that is not fussiness.
+ * `@vitnode/core` is imported from `dist` by the apps and from `src` by its own
+ * tests, so two copies of this class can exist in one process and `instanceof`
+ * would answer `false` across them.
+ */
+export class DevicesRequestError extends Error {
+ constructor(status: number) {
+ super(`The devices API answered ${status} for the current user's devices.`);
+ this.name = DEVICES_REQUEST_ERROR;
+ this.status = status;
+ }
+
+ readonly status: number;
+}
+
+export const isDevicesRequestError = (
+ error: unknown,
+): error is DevicesRequestError =>
+ error instanceof Error && error.name === DEVICES_REQUEST_ERROR;
+
+/**
+ * The list, fetched from the browser.
+ *
+ * `fetcherClient` builds the same same-origin `/api/@vitnode/core/users/devices`
+ * URL every other VitNode client call uses, so the browser attaches the session
+ * and device cookies itself - which is what makes `isCurrent` correct - and a
+ * `429` is routed to the global rate-limit notice on the way through.
+ */
+export const fetchDevicesInBrowser: DevicesFetcher = async () => {
+ const response = await fetcherClient(usersModuleRef, devicesRequest());
+
+ if (!response.ok) throw new DevicesRequestError(response.status);
+
+ return await response.json();
+};
+
+/**
+ * The cache entry one visitor's list reads and writes, and the target an
+ * invalidation names.
+ *
+ * A factory over the owner's id rather than the constant `["devices", "me"]` it
+ * replaces. The reasoning was wrong in one specific way and it is worth keeping
+ * the correction visible: it argued that the request carries no user, so the key
+ * needs none, and that "the QueryClient is per request on the server and per
+ * browser on the client, so there is no client holding two visitors' lists".
+ *
+ * The last clause is the mistake. *Per browser* is not per visitor - the browser
+ * client is created once per document and outlives a sign-out:
+ *
+ * A signs in -> /settings/devices -> ["devices","me"] holds A's devices
+ * A signs out
+ * B signs in -> /settings/devices -> the loader asks for the same entry
+ *
+ * which is already populated, and with `refetchOnMount` off nothing refetches
+ * it. B would be shown A's operating systems, browsers and IP addresses without
+ * a single request being made - so Hono never sees the read it would have
+ * refused. Keyed by owner, B's entry is empty and the fetch happens.
+ *
+ * The locale is deliberately absent. Operating system, browser, IP address and
+ * both timestamps are the same data in every language; the only translated
+ * things on the page are the labels and the relative date, which the renderer
+ * resolves from the provider it is under. A locale in the key would mean a
+ * language switch silently refetched a list that had not changed.
+ *
+ * ## The id addresses a cache, it does not identify a caller
+ *
+ * `GET /users/devices` still takes no parameters and still derives the user from
+ * the session cookie - {@link devicesRequest} is unchanged. So this id decides
+ * which cache slot the answer is filed under and authorizes nothing; sending it
+ * would turn a cache key into an access-control parameter, which is the one
+ * thing it must never become.
+ *
+ * There is one entry per visitor and it has no sub-keys, so this is both the key
+ * and the family an invalidation names.
+ */
+export const devicesQueryKey = (userId: number) =>
+ ["devices", "user", userId] as const;
+
+/**
+ * The visitor's devices, as the one query definition every caller shares.
+ *
+ * A route loader warms it before the component renders:
+ *
+ * context.queryClient.ensureQueryData(
+ * devicesQueryOptions({ fetchDevices, userId }),
+ * )
+ *
+ * and the component reads the very same options back:
+ *
+ * const { data } = useSuspenseQuery(devicesQuery(userId))
+ *
+ * Same key, same request, same status checking - so the loader's list is the
+ * list the component renders, and a revoke that invalidates
+ * {@link devicesQueryKey} refetches through the identical contract.
+ *
+ * `userId` addresses the cache and nothing else - see {@link devicesQueryKey}.
+ * It is required, and the whole parameter object with it, because there is no
+ * honest default: falling back to a shared entry is the bug this closes. Both
+ * callers take it from the one place that knows it, the `_authenticated` route
+ * context, so the loader and the component cannot land on two partitions.
+ *
+ * `fetchDevices` is the seam. It defaults to the browser's fetcher, which is what
+ * a hydrated page wants; an app that also fetches during SSR passes one that can
+ * do both. It is a plain async function rather than anything framework-shaped, so
+ * nothing about this module knows which framework is rendering it.
+ *
+ * ## It asks once
+ *
+ * `retry: false`, against Query's default of three attempts. Every failure this
+ * read can produce is made worse by repeating it: a `429` is answered by sending
+ * the same request two more times, which is the thing the limiter is asking this
+ * app to stop doing, and a `401` is not going to become a `200` because we asked
+ * again. The visitor retries by reloading - a decision they can make and a rate
+ * limiter can see coming.
+ *
+ * No `staleTime`. Freshness is whatever the API's own caching gives, plus
+ * VitNode's client defaults (`refetchOnMount` and `refetchOnWindowFocus` both
+ * off), so a hydrated list is not refetched behind the reader; a revoke is what
+ * makes it stale, explicitly.
+ */
+export const devicesQueryOptions = ({
+ fetchDevices = fetchDevicesInBrowser,
+ userId,
+}: {
+ fetchDevices?: DevicesFetcher;
+ userId: number;
+}) =>
+ queryOptions({
+ // `userId` is deliberately absent from the request: the owner comes from
+ // the session cookie, on the server, on every call.
+ queryFn: async () => await fetchDevices(),
+ queryKey: devicesQueryKey(userId),
+ retry: false,
+ });
+
+/**
+ * What the shared list accepts, and the reason it accepts only this.
+ *
+ * Typed as the factory's own return type on purpose: a caller cannot hand the
+ * list a hand-rolled options object that happens to type-check, so "one query
+ * definition" is enforced by the compiler rather than by review.
+ */
+export type DevicesQueryOptions = ReturnType;
diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts b/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts
new file mode 100644
index 000000000..ec9bcbe00
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts
@@ -0,0 +1,210 @@
+import { fetcherClient } from "@/lib/fetcher-client";
+
+import type { Device } from "./devices-query";
+
+import { usersModuleRef } from "./devices-query";
+
+/**
+ * Signing one device out, as a contract both frameworks satisfy.
+ *
+ * The API already accepts an authenticated `DELETE` from anywhere: it derives the
+ * user from the session cookie, scopes the lookup to their own sessions, and
+ * refuses the device the request itself is coming from. So the browser calls it
+ * directly - same origin, cookie attached by the browser itself - and there is
+ * deliberately no server function in between. A server function here would be a
+ * `POST` back to the app that then calls Hono, which is two round trips and a
+ * second place to get the semantics wrong, in exchange for nothing: this
+ * mutation needs no server-only secret, and it sets no cookie that would have to
+ * be copied onto a response.
+ *
+ * The Next.js app keeps its server action, which is not a contradiction. There
+ * the revoke has to end with `revalidatePath`, and that only exists on a server;
+ * see `revoke-action.server.ts`. What both sides share is the *shape* - the
+ * callback type below, the request, the status mapping and the refresh rule - so
+ * one list component can be handed either.
+ *
+ * ## What it cannot do
+ *
+ * Revoke the current device. `DELETE /users/devices/{publicId}` compares the id
+ * to the requester's own device cookie and answers `400` before it deletes
+ * anything, so there is no path through this module that can end the session
+ * making the call. That is why nothing here touches the session cache: the one
+ * mutation that would invalidate it is the one the API refuses. See
+ * {@link isRevokableDevice} and {@link REVOKE_CURRENT_DEVICE_STATUS}.
+ *
+ * The guard has no gap, and that is worth stating because "the device cookie was
+ * missing, so no row was current" would be one. `SessionModel.getUser()` - which
+ * is what fills `c.get("user")` for every request - resolves the device from that
+ * same cookie and looks the session up by `(token, deviceId)`. A request with no
+ * usable device cookie therefore has no user at all and is answered `401` before
+ * either route reads the cookie. So on every response these two routes can
+ * actually produce, the cookie names the device holding the requesting session:
+ * exactly one row is `isCurrent`, and it is precisely the one that cannot be
+ * revoked.
+ */
+
+/** Signing out one device. The id is the row's own `publicId`. */
+export interface RevokeDeviceArgs {
+ publicId: string;
+}
+
+/**
+ * The finite outcome of one revoke.
+ *
+ * A closed result rather than a rejection, so a Next.js server action and a
+ * browser fetch are the same prop: the caller is standing in a confirm dialog
+ * and has to say something either way. `status` carries which refusal it was,
+ * because the three that matter read differently - see
+ * {@link REVOKE_CURRENT_DEVICE_STATUS}.
+ */
+export interface RevokeDeviceResult {
+ data?: true;
+ error?: {
+ status: number;
+ };
+}
+
+/**
+ * What the shared list is handed instead of a mutation.
+ *
+ * A plain async function returning a closed result. Nothing framework-shaped
+ * survives in either direction.
+ */
+export type RevokeDevice = (
+ args: RevokeDeviceArgs,
+) => Promise;
+
+/**
+ * The status the API answers when asked to revoke the device doing the asking.
+ *
+ * Named rather than spelled `400` at the call site because it is the one refusal
+ * with a meaning instead of a cause: the request was well-formed and the device
+ * exists, and the answer is "not that one". The list does not offer the button
+ * for it, so reaching this means the row was stale - the same device cookie was
+ * re-issued, or another tab signed in - and the honest repair is to refetch,
+ * which is what {@link shouldRefreshAfterRevoke} does.
+ */
+export const REVOKE_CURRENT_DEVICE_STATUS = 400;
+
+/**
+ * Whether a row may be signed out at all.
+ *
+ * The current device may not, and the API is the one enforcing it. This is the
+ * *display* half of that rule, kept next to the request so the two cannot drift:
+ * a list that offered the button anyway would put a `400` behind it, and the only
+ * thing the person would learn is that something went wrong.
+ */
+export const isRevokableDevice = (device: Pick): boolean =>
+ !device.isCurrent;
+
+/**
+ * The public ids this module will send, and the shape of one it will not.
+ *
+ * `randomBytes(16).toString("hex")` is what `DeviceModel` mints, so a real id is
+ * 32 hex characters; the pattern is deliberately wider than that - any URL-safe
+ * token up to 128 characters - so a deployment whose ids were minted by an
+ * earlier scheme keeps working. What it rules out is the two shapes that are
+ * never an id and would be sent into a path segment: empty, and anything
+ * carrying `/`, `.` or a percent-escape.
+ *
+ * Refusing locally rather than letting the API answer is the point. The route's
+ * own `z.string()` accepts `""` and `../session`, and the fetcher interpolates
+ * the value into `/devices/{publicId}` - so an empty id addresses the *list*
+ * route with a `DELETE` and a traversal addresses a sibling. Both come back as
+ * some other status, which the dialog would report as a mysterious failure.
+ */
+const DEVICE_PUBLIC_ID = /^[A-Za-z0-9_-]{1,128}$/;
+
+export const isDevicePublicId = (publicId: string): boolean =>
+ DEVICE_PUBLIC_ID.test(publicId);
+
+/**
+ * One revoke, as arguments to whichever fetcher is carrying it.
+ *
+ * Shared with the Next.js server action, so a revoke is the same request in both
+ * applications rather than two places that merely look alike.
+ */
+export const revokeDeviceRequest = ({ publicId }: RevokeDeviceArgs) =>
+ ({
+ args: { params: { publicId } },
+ method: "delete" as const,
+ module: "users" as const,
+ path: "/devices/{publicId}" as const,
+ }) as const;
+
+/**
+ * The result a refused status becomes.
+ *
+ * Its own function because both transports have to agree on it, and because
+ * "which statuses count as done" is the kind of rule that grows a second
+ * spelling the moment it is inlined twice. `200` is the only success the route
+ * declares - it answers with an empty body - and everything else is the status,
+ * verbatim, for the caller to phrase.
+ */
+export const revokeResultFromStatus = (status: number): RevokeDeviceResult =>
+ status === 200 ? { data: true } : { error: { status } };
+
+/**
+ * Signs one device out from the browser.
+ *
+ * Never rejects, and that is the contract rather than an oversight. Every way
+ * this can fail is something the person has to be told in the dialog they are
+ * standing in, and a rejected promise would have to be caught by every caller to
+ * say the same thing.
+ *
+ * The `catch` is why the `500` case is not special: `rawApiFetch` throws on those
+ * with the failing URL and the server's own error text attached, and that throw
+ * is a server error like any other - reported as `status: 500`, not as a crashed
+ * dialog.
+ *
+ * A locally-refused id is reported as `400`, which is both the honest status -
+ * the request was malformed, and never sent - and the same one the route's own
+ * schema would have produced had it been. It coincides with
+ * {@link REVOKE_CURRENT_DEVICE_STATUS} and that costs nothing: both mean the row
+ * on screen does not match the server, and both are answered by refetching.
+ */
+export const revokeDeviceInBrowser: RevokeDevice = async ({ publicId }) => {
+ if (!isDevicePublicId(publicId)) return { error: { status: 400 } };
+
+ try {
+ const response = await fetcherClient(usersModuleRef, {
+ ...revokeDeviceRequest({ publicId }),
+ options: { credentials: "include" },
+ });
+
+ return revokeResultFromStatus(response.status);
+ } catch {
+ return { error: { status: 500 } };
+ }
+};
+
+/**
+ * Whether a finished revoke changed what the list is showing.
+ *
+ * Two cases, and the second is the one worth stating:
+ *
+ * - **It worked.** The row is gone, so the list is stale.
+ * - **`404`, or `400`.** The row was already wrong. A device somebody else
+ * revoked first is a `404`, and a row the list believed was revokable but the
+ * API considers current is a `400` - in both cases what is on screen does not
+ * match the server, and refetching is the repair.
+ *
+ * A `401`, `403`, `429` or `500` is deliberately *not* a refresh. Nothing was
+ * deleted, and the refetch would be a second request into whatever refused the
+ * first - a rate limiter answered by immediately asking again, or an ended
+ * session answered by a second `401` that blanks the list the person is looking
+ * at. The dialog says it failed and the list stays exactly as it was.
+ *
+ * The Next.js action applies the same rule before it calls `revalidatePath`, so
+ * both frameworks refresh on the same condition.
+ */
+export const shouldRefreshAfterRevoke = ({
+ data,
+ error,
+}: RevokeDeviceResult): boolean => {
+ if (data) return true;
+
+ return (
+ error?.status === 404 || error?.status === REVOKE_CURRENT_DEVICE_STATUS
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts b/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts
index cfcdc5341..d8abad739 100644
--- a/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts
+++ b/packages/vitnode/src/views/auth/settings/devices/revoke-action.server.ts
@@ -5,25 +5,45 @@ import { revalidatePath } from "next/cache";
import { usersModule } from "@/api/modules/users/users.module";
import { fetcher } from "@/lib/fetcher";
-export const revokeDeviceAction = async ({
- publicId,
-}: {
- publicId: string;
-}): Promise<{ data?: true; error?: { status: number } }> => {
- const res = await fetcher(usersModule, {
- path: "/devices/{publicId}",
- method: "delete",
- module: "users",
- args: {
- params: { publicId },
- },
- });
-
- if (res.status !== 200) {
- return { error: { status: res.status } };
- }
+import type { RevokeDevice } from "./devices-revoke";
+
+import {
+ isDevicePublicId,
+ revokeDeviceRequest,
+ revokeResultFromStatus,
+ shouldRefreshAfterRevoke,
+} from "./devices-revoke";
+
+/**
+ * Signing one device out, from a Next.js page.
+ *
+ * The Next.js half of the revoke, and the only part of it that is Next.js's: the
+ * request, the id check and the status mapping all come from `devices-revoke.ts`,
+ * which is also what the TanStack Start app's browser fetch is built from. So a
+ * revoke means the same thing in both applications, and the `RevokeDevice` type
+ * this satisfies is the prop the shared button takes.
+ *
+ * What remains here is `revalidatePath`, which exists only on a server and is how
+ * a Next.js page refreshes. Its TanStack Start counterpart is a query
+ * invalidation of `DEVICES_QUERY_KEY`; both are applied on the same condition -
+ * `shouldRefreshAfterRevoke` - so neither refreshes a list the API left
+ * untouched. A `429` answered by re-rendering the page would send the same read
+ * straight back into the limiter, and a `401` would replace the list with a
+ * not-found while the person is reading it.
+ *
+ * The layout, not the page: revoking a device changes the sessions the header and
+ * the sidebar are rendered from as well as the list, and `'layout'` is what the
+ * previous version already said.
+ */
+export const revokeDeviceAction: RevokeDevice = async ({ publicId }) => {
+ if (!isDevicePublicId(publicId)) return { error: { status: 400 } };
- revalidatePath("/[locale]/(main)", "layout");
+ const res = await fetcher(usersModule, revokeDeviceRequest({ publicId }));
+ const result = revokeResultFromStatus(res.status);
+
+ if (shouldRefreshAfterRevoke(result)) {
+ revalidatePath("/[locale]/(main)", "layout");
+ }
- return { data: true };
+ return result;
};
diff --git a/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx b/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
index 060c58a40..bd28eba97 100644
--- a/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
+++ b/packages/vitnode/src/views/auth/settings/devices/revoke-device-button.tsx
@@ -1,18 +1,42 @@
"use client";
import { LogOutIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import { toast } from "sonner";
+import { useTranslations } from "use-intl";
import { ConfirmActionAlertDialog } from "@/components/confirm-action/confirm-action-alert-dialog";
import { Button } from "@/components/ui/button";
-import { revokeDeviceAction } from "./revoke-action.server";
+import type { RevokeDevice } from "./devices-revoke";
+/**
+ * Signing one device out, as a button both frameworks render.
+ *
+ * What used to make this Next.js-only was one import: the server action, which
+ * ends in `revalidatePath` and drags `next/headers` and the whole API module
+ * graph behind it. It is a prop now - `onRevoke` - so the Next.js page passes
+ * the action and the TanStack Start route passes a browser fetch that ends in a
+ * query invalidation, and everything visible here is the same in both.
+ *
+ * `useTranslations` from `use-intl` rather than from `next-intl`, for the same
+ * reason: `next-intl`'s root entry re-exports these APIs and is framework-free,
+ * but naming it here would be one more thing a non-Next app has to happen to
+ * resolve. The strings come from whichever provider is above - `I18nProvider` in
+ * Next.js, `RouteMessages` in TanStack Start - and both mount `core.global`
+ * alongside `core.auth.settings`, which is what the confirm dialog's own buttons
+ * need.
+ *
+ * The result is *reported*, never thrown. `onRevoke` returns a closed
+ * `RevokeDeviceResult` in both applications, so this component's whole error
+ * handling is one branch, and it stays identical whether the failure was a
+ * refused status or a server that was not listening.
+ */
export const RevokeDeviceButton = ({
+ onRevoke,
os,
publicId,
}: {
+ onRevoke: RevokeDevice;
os: string;
publicId: string;
}) => {
@@ -23,7 +47,8 @@ export const RevokeDeviceButton = ({
{
- const result = await revokeDeviceAction({ publicId });
+ const result = await onRevoke({ publicId });
+
if (result.error) {
toast.error(tGlobal("title"), {
description: tGlobal("internal_server_error"),
diff --git a/packages/vitnode/src/views/auth/settings/nav-content.tsx b/packages/vitnode/src/views/auth/settings/nav-content.tsx
new file mode 100644
index 000000000..92a3b47d3
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/nav-content.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import {
+ ChevronRightIcon,
+ KeyRoundIcon,
+ MonitorSmartphoneIcon,
+ UserRoundIcon,
+} from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { buttonVariants } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+import type { AuthLinkComponent } from "../auth-link";
+import type { SettingsNavKey } from "./settings-nav";
+
+import { isSettingsNavItemActive, SETTINGS_NAV_ITEMS } from "./settings-nav";
+
+/**
+ * The settings navigation, with the two things it cannot resolve for itself
+ * handed in.
+ *
+ * `pathname` rather than a hook, and `LinkComponent` rather than an import: both
+ * are the same seam `HeaderContent` and `SearchFeedContent` already draw, and
+ * both exist for the same reason. `usePathname` and a locale-aware `Link` come
+ * from `next-intl` in the Next.js app and from the router in TanStack Start, and
+ * importing either here would make this module Next-only - which is exactly what
+ * `views/auth/auth-boundaries.test.ts` pins.
+ *
+ * The pathname is *internal* - no locale prefix. Each framework's wrapper hands
+ * over the spelling its own router uses, and nothing here localizes an href
+ * either: `LinkComponent` does that, once.
+ */
+const ICONS: Record = {
+ devices: MonitorSmartphoneIcon,
+ overview: UserRoundIcon,
+ security: KeyRoundIcon,
+};
+
+export const SettingsNavContent = ({
+ LinkComponent,
+ pathname,
+}: {
+ LinkComponent: AuthLinkComponent;
+ pathname: string;
+}) => {
+ const t = useTranslations("core.auth.settings.nav");
+
+ return (
+
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/nav.tsx b/packages/vitnode/src/views/auth/settings/nav.tsx
index 5a0d5cd6a..6d4ceaceb 100644
--- a/packages/vitnode/src/views/auth/settings/nav.tsx
+++ b/packages/vitnode/src/views/auth/settings/nav.tsx
@@ -1,60 +1,18 @@
"use client";
-import {
- ChevronRightIcon,
- KeyRoundIcon,
- MonitorSmartphoneIcon,
- UserRoundIcon,
-} from "lucide-react";
-import { useTranslations } from "next-intl";
+import { usePathname } from "@/lib/navigation";
-import { buttonVariants } from "@/components/ui/button";
-import { Link, usePathname } from "@/lib/navigation";
-import { cn, normalizeUrl } from "@/lib/utils";
+import { NextAuthLink } from "../next-link";
+import { SettingsNavContent } from "./nav-content";
-const items = [
- {
- href: "/settings/overview",
- key: "overview",
- icon: UserRoundIcon,
- aliases: ["/settings"],
- },
- {
- href: "/settings/devices",
- key: "devices",
- icon: MonitorSmartphoneIcon,
- },
- { href: "/settings/security", key: "security", icon: KeyRoundIcon },
-] as const;
-
-export const NavSettings = () => {
- const t = useTranslations("core.auth.settings.nav");
- const pathname = normalizeUrl(usePathname());
-
- return (
-
- );
-};
+/**
+ * {@link SettingsNavContent}, wired to Next.js.
+ *
+ * The two framework-specific halves and nothing else: `next-intl`'s locale-aware
+ * `usePathname`, which answers with the internal path the route tree uses, and
+ * the same `Link` every other auth screen renders. Which items exist and which
+ * one is selected is `settings-nav.ts`, shared.
+ */
+export const NavSettings = () => (
+
+);
diff --git a/packages/vitnode/src/views/auth/settings/overview/overview.tsx b/packages/vitnode/src/views/auth/settings/overview/overview.tsx
index 629981645..1e5530083 100644
--- a/packages/vitnode/src/views/auth/settings/overview/overview.tsx
+++ b/packages/vitnode/src/views/auth/settings/overview/overview.tsx
@@ -1,9 +1,27 @@
-import { getTranslations } from "next-intl/server";
+"use client";
+
+import { useTranslations } from "use-intl";
import { HeaderContent } from "@/components/ui/header-content";
-export const OverviewSettings = async () => {
- const t = await getTranslations("core.auth.settings.nav");
+/**
+ * The overview panel, which is currently a heading.
+ *
+ * Rendered by two URLs in each framework: `/settings`, whose root screen shows
+ * the overview rather than redirecting to it, and `/settings/overview`. See
+ * `SETTINGS_NAV_ITEMS` for why the root is an alias and not a redirect.
+ *
+ * A client component reading `use-intl` rather than a Server Component reading
+ * `next-intl/server`, which is what lets a TanStack Start route render it: the
+ * strings come from whichever provider is above it - `I18nProvider` in Next.js,
+ * `RouteMessages` in TanStack Start - and both mount `core.auth.settings`.
+ *
+ * There is deliberately nothing else here. Profile editing, email changes and
+ * the rest are not features VitNode has yet, and the route name is not a
+ * specification.
+ */
+export const OverviewSettings = () => {
+ const t = useTranslations("core.auth.settings.nav");
return ;
};
diff --git a/packages/vitnode/src/views/auth/settings/security/security.tsx b/packages/vitnode/src/views/auth/settings/security/security.tsx
index 02616452c..5cde30e18 100644
--- a/packages/vitnode/src/views/auth/settings/security/security.tsx
+++ b/packages/vitnode/src/views/auth/settings/security/security.tsx
@@ -1,9 +1,23 @@
-import { getTranslations } from "next-intl/server";
+"use client";
+
+import { useTranslations } from "use-intl";
import { HeaderContent } from "@/components/ui/header-content";
-export const SecuritySettings = async () => {
- const t = await getTranslations("core.auth.settings.nav");
+/**
+ * The security panel, which is currently a heading.
+ *
+ * A client component reading `use-intl` rather than a Server Component reading
+ * `next-intl/server`, for the reason `OverviewSettings` explains: it is rendered
+ * by a Next.js page and by a TanStack Start route, and only one of those has a
+ * request scope.
+ *
+ * Passwords, two-factor enrolment, passkeys and a session log are not features
+ * VitNode has yet. This file is what `/settings/security` does today, and the
+ * route name is not a specification.
+ */
+export const SecuritySettings = () => {
+ const t = useTranslations("core.auth.settings.nav");
return ;
};
diff --git a/packages/vitnode/src/views/auth/settings/settings-nav.ts b/packages/vitnode/src/views/auth/settings/settings-nav.ts
new file mode 100644
index 000000000..1df8e4f27
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/settings-nav.ts
@@ -0,0 +1,98 @@
+import { normalizeUrl } from "@/lib/utils";
+
+/**
+ * The settings screens, as data rather than as markup.
+ *
+ * Two decisions live here and nowhere else: which panels the settings navigation
+ * offers, and which one of them a given path is on. Both are plain functions
+ * over strings - no router, no request, no React - because both frameworks have
+ * to reach the same answer from the URL each of them happens to hold, and a
+ * highlighted nav item disagreeing with the panel on screen is the kind of bug
+ * that only shows up on one of the two.
+ *
+ * What this is *not*: a route table. Neither framework learns which routes exist
+ * from this file - Next.js has `routes/main/settings/*` and TanStack Start has
+ * `routes/_main/_authenticated/settings/*`, and a panel that is not routed
+ * simply renders a link to a 404. The list is the navigation's contents, which
+ * is a product decision, and it is shared so the two navigations cannot offer
+ * different menus.
+ */
+
+/** Where the settings screens are rooted, and the mobile "back" destination. */
+export const SETTINGS_ROOT_HREF = "/settings";
+
+export type SettingsNavKey = "devices" | "overview" | "security";
+
+export interface SettingsNavItem {
+ /**
+ * Paths that light this item up without being its own href.
+ *
+ * `/settings` is the only one, and it exists because the root path renders the
+ * overview panel rather than redirecting to it - see the note on
+ * {@link SETTINGS_NAV_ITEMS}. Without the alias, the root screen would show a
+ * navigation with nothing selected.
+ */
+ aliases: readonly string[];
+ href: string;
+ /** The `core.auth.settings.nav` key this item's label comes from. */
+ key: SettingsNavKey;
+}
+
+/**
+ * The settings navigation, in the order it is rendered.
+ *
+ * `/settings` is an alias of the overview panel rather than a redirect to it,
+ * and that is deliberate on both sides of the seam. The shell shows the
+ * navigation *instead of* the panel on a narrow screen (see
+ * {@link isSettingsRootPath}), so a visitor who lands on `/settings` from a
+ * phone is looking at a menu; redirecting them to `/settings/overview` would
+ * skip the menu entirely and leave the back link as the only way to reach it.
+ */
+export const SETTINGS_NAV_ITEMS: readonly SettingsNavItem[] = [
+ {
+ aliases: [SETTINGS_ROOT_HREF],
+ href: "/settings/overview",
+ key: "overview",
+ },
+ { aliases: [], href: "/settings/devices", key: "devices" },
+ { aliases: [], href: "/settings/security", key: "security" },
+];
+
+/**
+ * Whether `pathname` is the settings root.
+ *
+ * The pathname must already be *internal* - no locale prefix. Next.js gets that
+ * from `next-intl`'s `usePathname`, TanStack Start from a router location the
+ * Stage 3 rewrite has stripped. Nothing here localizes anything, and nothing
+ * here may start to: a rule that compared against `/pl/settings` would be a
+ * second copy of the locale routing.
+ */
+export const isSettingsRootPath = (pathname: string): boolean =>
+ normalizeUrl(pathname) === SETTINGS_ROOT_HREF;
+
+/** Whether one navigation item is the panel `pathname` is showing. */
+export const isSettingsNavItemActive = (
+ item: SettingsNavItem,
+ pathname: string,
+): boolean =>
+ [item.href, ...item.aliases].some(
+ href => normalizeUrl(href) === normalizeUrl(pathname),
+ );
+
+/**
+ * Which panel `pathname` is on, or nothing.
+ *
+ * `undefined` for a path outside the settings screens, and for a settings path
+ * with no navigation entry - a future panel reachable by URL before it is
+ * listed. The navigation renders nothing selected in both cases, which is the
+ * honest answer.
+ */
+export const activeSettingsNavKey = (
+ pathname: string,
+): SettingsNavKey | undefined =>
+ SETTINGS_NAV_ITEMS.find(item => isSettingsNavItemActive(item, pathname))?.key;
+
+/** One navigation item's own href, by key. */
+export const settingsNavHref = (key: SettingsNavKey): string =>
+ SETTINGS_NAV_ITEMS.find(item => item.key === key)?.href ??
+ `${SETTINGS_ROOT_HREF}/${key}`;
diff --git a/packages/vitnode/src/views/auth/settings/shell-content.tsx b/packages/vitnode/src/views/auth/settings/shell-content.tsx
new file mode 100644
index 000000000..ba8b21bb3
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/shell-content.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { ArrowLeftIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { buttonVariants } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { HeaderContent } from "@/components/ui/header-content";
+import { cn } from "@/lib/utils";
+
+import type { AuthLinkComponent } from "../auth-link";
+
+import { SETTINGS_ROOT_HREF } from "./settings-nav";
+
+/**
+ * The settings screens' frame: the heading, the navigation card, and the panel
+ * every settings page renders inside.
+ *
+ * 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 layout route renders exactly the frame the Next.js layout
+ * renders.
+ *
+ * Two things arrive from outside, and they are the only two:
+ *
+ * - `nav`, a slot. Each framework builds its own navigation because each has its
+ * own `Link` and its own way of knowing where it is; what the menu *contains*
+ * is shared, in `settings-nav.ts`.
+ * - `BackLink`, a component. The mobile back link's markup is presentation and
+ * stays here, so the two frameworks cannot drift into two different buttons -
+ * only the anchor underneath it differs.
+ *
+ * ## `isRoot` is a prop, not a hook call
+ *
+ * The whole of the mobile behaviour: on a narrow screen `/settings` shows the
+ * heading and the menu, and a panel path shows the panel with a link back to the
+ * menu. Both cards render in both cases and one of the two is hidden, so a
+ * desktop layout is one grid rather than two - which is why this is a class name
+ * rather than a branch.
+ *
+ * Deciding it needs the current path, which is the one thing this module must
+ * not read for itself (see {@link SettingsNavContent}). `isSettingsRootPath` in
+ * `settings-nav.ts` is the shared rule; each framework applies it to the
+ * pathname its own router holds.
+ */
+export const SettingsShellContent = ({
+ BackLink,
+ children,
+ isRoot,
+ nav,
+}: {
+ BackLink: AuthLinkComponent;
+ children: React.ReactNode;
+ isRoot: boolean;
+ nav: React.ReactNode;
+}) => {
+ const t = useTranslations("core.auth.settings");
+
+ return (
+
+
+
+
+
+ {nav}
+
+
+
+
+
+
+ {t("title")}
+
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/shell.tsx b/packages/vitnode/src/views/auth/settings/shell.tsx
index 78625dc04..559923814 100644
--- a/packages/vitnode/src/views/auth/settings/shell.tsx
+++ b/packages/vitnode/src/views/auth/settings/shell.tsx
@@ -1,59 +1,26 @@
"use client";
-import { ArrowLeftIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
-
-import { buttonVariants } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import { HeaderContent } from "@/components/ui/header-content";
-import { Link, usePathname } from "@/lib/navigation";
-import { cn, normalizeUrl } from "@/lib/utils";
+import { usePathname } from "@/lib/navigation";
+import { NextAuthLink } from "../next-link";
import { NavSettings } from "./nav";
-
-export const SettingsShell = ({ children }: { children: React.ReactNode }) => {
- const t = useTranslations("core.auth.settings");
- const isRoot = normalizeUrl(usePathname()) === "/settings";
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {t("title")}
-
-
- {children}
-
-
-
-
- );
-};
+import { isSettingsRootPath } from "./settings-nav";
+import { SettingsShellContent } from "./shell-content";
+
+/**
+ * {@link SettingsShellContent}, wired to Next.js.
+ *
+ * Where Next.js enters the settings frame, and the only place it does: the
+ * pathname comes from `next-intl`, the back link is the shared auth `Link`, and
+ * the navigation is the Next.js wrapper. Everything visible is
+ * `shell-content.tsx`.
+ */
+export const SettingsShell = ({ children }: { children: React.ReactNode }) => (
+ }
+ >
+ {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 dfb64a5c5..e65b4f17c 100644
--- a/packages/vitnode/src/views/auth/sign-in/form/form.tsx
+++ b/packages/vitnode/src/views/auth/sign-in/form/form.tsx
@@ -16,9 +16,10 @@ import { SignInFormContent } from "./sign-in-form-content";
* 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.
+ * - **A `Link`** that knows how to write a locale prefix into an internal href -
+ * the "forgot your password" link, which points at `/login/reset-password`.
+ * That route is served by both applications now, and this wrapper is the
+ * Next.js one, so it links to the Next.js page as it always has.
*/
export const FormSignIn = ({
isAdmin,
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
index 561485268..0a2c4981c 100644
--- a/packages/vitnode/src/views/auth/sign-in/sign-in-card.tsx
+++ b/packages/vitnode/src/views/auth/sign-in/sign-in-card.tsx
@@ -18,7 +18,10 @@ import { SignInContent } from "./sign-in-content";
* 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.
+ * The "create an account" link points at `/register`, which is served by both
+ * applications now. This wrapper is the Next.js one, so it links to the Next.js
+ * page as it always has; the TanStack Start route hands `SignInContent` its own
+ * link component instead. See `AUTH_HREF` in `../auth-link.ts`.
*/
export const SignInCard = ({
form,
diff --git a/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx b/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx
index 35edb2085..18bbafa7d 100644
--- a/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx
+++ b/packages/vitnode/src/views/auth/sign-up/components/password-input.tsx
@@ -1,6 +1,6 @@
import { CheckIcon, XIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
import React from "react";
+import { useTranslations } from "use-intl";
import type { ItemAutoFormComponentProps } from "@/components/form/auto-form";
diff --git a/packages/vitnode/src/views/auth/sign-up/email-confirmation-view.tsx b/packages/vitnode/src/views/auth/sign-up/email-confirmation-view.tsx
index c1f3d6e9b..b67077f60 100644
--- a/packages/vitnode/src/views/auth/sign-up/email-confirmation-view.tsx
+++ b/packages/vitnode/src/views/auth/sign-up/email-confirmation-view.tsx
@@ -1,5 +1,5 @@
import { Mail, MailboxIcon } from "lucide-react";
-import { useTranslations } from "next-intl";
+import { useTranslations } from "use-intl";
import {
Card,
diff --git a/packages/vitnode/src/views/auth/sign-up/form/form.tsx b/packages/vitnode/src/views/auth/sign-up/form/form.tsx
index 5a383f56e..ae9e0c518 100644
--- a/packages/vitnode/src/views/auth/sign-up/form/form.tsx
+++ b/packages/vitnode/src/views/auth/sign-up/form/form.tsx
@@ -2,113 +2,36 @@
import type { z } from "zod";
-import { useTranslations } from "next-intl";
-
import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
-import {
- AutoForm,
- type ItemAutoFormComponentProps,
-} from "@/components/form/auto-form";
-import { AutoFormCheckbox } from "@/components/form/fields/checkbox";
-import { AutoFormInput } from "@/components/form/fields/input";
-import { Link } from "@/lib/navigation";
-import { removeSpecialCharacters } from "@/lib/special-characters";
-
-import { PasswordInput } from "../components/password-input";
-import { useFormSignUp } from "./use-form";
-
+import { NextAuthLink } from "../../next-link";
+import { mutationApi } from "./mutation-api.server";
+import { SignUpFormContent } from "./sign-up-form-content";
+
+/**
+ * {@link SignUpFormContent}, wired to Next.js.
+ *
+ * The props are unchanged, so `SignUpView` 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 creates the account, keeps the
+ * session cookie the API may have minted, revalidates the layout the session
+ * is rendered into and redirects - all of which are Next.js APIs, and all of
+ * which stay on this side of the boundary.
+ * - **A `Link`** that knows how to write a locale prefix into an internal href,
+ * for the terms-and-conditions link inside the checkbox description.
+ */
export const FormSignUp = ({
- isEmail,
captcha,
+ isEmail,
}: {
captcha: z.infer["captcha"];
isEmail: boolean;
-}) => {
- const t = useTranslations("core.auth.sign_up");
- const { onSubmit, formSchema } = useFormSignUp();
-
- return (
- {
- const value: string = field.value ?? "";
-
- return (
-
-
- {value.length >= 3 && (
-
- {t.rich("username.your_user_code", {
- code: () => (
-
- {removeSpecialCharacters(value)}
-
- ),
- })}
-
- )}
-
- );
- },
- },
- {
- id: "email",
- component: props => (
-
- ),
- },
- {
- id: "password",
- component: props => (
-
- ),
- },
- {
- id: "terms",
- component: props => (
- (
-
- {text}
-
- ),
- })}
- label={t("terms.label")}
- />
- ),
- },
- ...(isEmail
- ? [
- {
- id: "newsletter" as const,
- component: (props: ItemAutoFormComponentProps) => (
-
- ),
- },
- ]
- : []),
- ]}
- formSchema={formSchema}
- mode="all"
- onSubmit={onSubmit}
- submitButtonProps={{
- className: "w-full",
- children: t("submit"),
- }}
- />
- );
-};
+}) => (
+
+);
diff --git a/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts b/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts
index 4c391645e..51d31f23d 100644
--- a/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts
+++ b/packages/vitnode/src/views/auth/sign-up/form/mutation-api.server.ts
@@ -1,19 +1,33 @@
"use server";
-import type { z } from "zod";
-
import { revalidatePath } from "next/cache";
-import type { zodSignUpSchema } from "@/api/modules/users/routes/sign-up.route";
-
import { usersModule } from "@/api/modules/users/users.module";
import { fetcher } from "@/lib/fetcher";
import { redirect } from "@/lib/navigation";
+import type { SignUpMutationResult, SignUpSubmitValues } from "./schema";
+
+import { signUpConflictReason } from "./schema";
+
+/**
+ * Registration for Next.js: create the account, then either land the visitor on
+ * the front page or hand the form back the reason it could not.
+ *
+ * `allowSaveCookies: true` is load bearing. On a deployment with no email
+ * adapter the API marks the account verified and mints a session on the *same*
+ * `201`, so the reply carries a `Set-Cookie` the browser has to keep - without
+ * it the visitor is registered and immediately anonymous.
+ *
+ * The answer is narrowed to {@link SignUpMutationResult} here rather than in the
+ * form: this is the only layer that sees the API's body, and the 409 message it
+ * writes (`"Email already exists"`) is an internal string that must not reach a
+ * screen.
+ */
export const mutationApi = async ({
captchaToken,
...input
-}: z.infer & { captchaToken: string }) => {
+}: SignUpSubmitValues): Promise => {
const res = await fetcher(usersModule, {
path: "/sign_up",
method: "post",
@@ -25,15 +39,25 @@ export const mutationApi = async ({
},
});
- if (res.status !== 201) {
- return { error: await res.text() };
+ if (res.status === 409) {
+ const conflict = signUpConflictReason(await res.text());
+
+ return {
+ message: conflict === "unknown" ? "Internal Server Error" : conflict,
+ };
}
+ if (res.status !== 201) return { message: "Internal Server Error" };
+
const data = await res.json();
- if (data.emailVerified) {
- revalidatePath("/[locale]/(main)", "layout");
- await redirect("/");
- }
- return { data };
+ if (!data.emailVerified) return { emailConfirmation: data.email };
+
+ revalidatePath("/[locale]/(main)", "layout");
+ await redirect("/");
+
+ // `redirect()` throws, so this is unreachable - it exists so the function's
+ // type is the closed union the shared form reads rather than
+ // `... | undefined` inferred from a fall-through.
+ return undefined;
};
diff --git a/packages/vitnode/src/views/auth/sign-up/form/schema.test.ts b/packages/vitnode/src/views/auth/sign-up/form/schema.test.ts
new file mode 100644
index 000000000..71774899f
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/form/schema.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ createPasswordZodSchema,
+ createSignUpFormSchema,
+ signUpConflictReason,
+ signUpFormOutcome,
+} from "./schema";
+
+const messages = {
+ fieldRequired: "required",
+ invalidEmail: "not an email",
+ invalidPassword: "too weak",
+ nameMaxLength: "too long",
+ nameMinLength: "too short",
+ termsRequired: "tick the box",
+};
+
+const schema = createSignUpFormSchema(messages);
+
+const valid = {
+ email: "test@test.com",
+ name: "tester",
+ password: "Test123!",
+ terms: true,
+};
+
+describe("the sign-up schema", () => {
+ it("accepts a complete registration", () => {
+ const parsed = schema.safeParse(valid);
+
+ expect(parsed.success).toBe(true);
+ expect(parsed.data).toEqual({
+ email: "test@test.com",
+ name: "tester",
+ newsletter: false,
+ password: "Test123!",
+ terms: true,
+ });
+ });
+
+ it.each([
+ ["a name shorter than three characters", { name: "ab" }, "too short"],
+ ["a name longer than 32 characters", { name: "a".repeat(33) }, "too long"],
+ ["a value that is not an email address", { email: "test" }, "not an email"],
+ ["an unticked terms checkbox", { terms: false }, "tick the box"],
+ ])("rejects %s with the message it was given", (_case, patch, message) => {
+ const parsed = schema.safeParse({ ...valid, ...patch });
+
+ expect(parsed.success).toBe(false);
+ expect(parsed.error?.issues[0]?.message).toBe(message);
+ });
+
+ it("defaults the fields AutoForm builds its initial values from", () => {
+ // A field without a default renders as an uncontrolled input. `email` is
+ // deliberately absent from this list: it has no default today, and
+ // `AutoFormInput` covers it with `value={field.value ?? ""}`.
+ expect(schema.shape.name.def.defaultValue).toBe("");
+ expect(schema.shape.password.def.defaultValue).toBe("");
+ expect(schema.shape.terms.def.defaultValue).toBe(false);
+ });
+
+ it("carries the messages it was built with, not a fixed language", () => {
+ const polish = createSignUpFormSchema({
+ ...messages,
+ invalidEmail: "nieprawidłowy adres e-mail",
+ });
+ const parsed = polish.safeParse({ ...valid, email: "test" });
+
+ expect(parsed.error?.issues[0]?.message).toBe("nieprawidłowy adres e-mail");
+ });
+});
+
+describe("the password rules", () => {
+ const password = createPasswordZodSchema({
+ fieldRequired: "required",
+ invalidPassword: "too weak",
+ });
+
+ it.each([
+ ["Test123!", true],
+ ["Sufficiently1Long!", true],
+ // Eight characters, an uppercase, a digit and a non-word character are all
+ // required - the four `.regex()` calls, one per row below.
+ ["Test12!", false],
+ ["test123!", false],
+ ["TestTest!", false],
+ ["Test1234", false],
+ ])("reads %s as acceptable: %s", (value, expected) => {
+ expect(password.safeParse(value).success).toBe(expected);
+ });
+
+ it("treats an underscore as a special character", () => {
+ // `\W|_` - an underscore is a word character, so it needs the second half.
+ expect(password.safeParse("Test123_").success).toBe(true);
+ });
+
+ it("says the same thing whichever rule failed", () => {
+ // The live checklist in `PasswordInput` is what says *which* rule; the
+ // message is the same one either way, which is why it is one string.
+ for (const value of ["short1A!", "nouppercase1!", "NoDigits!"]) {
+ const parsed = password.safeParse(value);
+ if (parsed.success) continue;
+
+ expect(parsed.error.issues[0]?.message).toBe("too weak");
+ }
+ });
+
+ it("requires the field, with the message it was given", () => {
+ expect(password.safeParse(undefined).success).toBe(true); // the default
+ expect(password.safeParse(42).error?.issues[0]?.message).toBe("required");
+ });
+});
+
+describe("classifying a 409", () => {
+ it.each([
+ ["Email already exists", "email_exists"],
+ ["Name already exists", "name_exists"],
+ // Case and surrounding whitespace are the API's business, not a reason to
+ // fall back to the generic failure.
+ [" name already exists ", "name_exists"],
+ ])("reads %s as %s", (body, expected) => {
+ expect(signUpConflictReason(body)).toBe(expected);
+ });
+
+ it.each([
+ ['{"error":"Email already exists"}', "email_exists"],
+ ['{"message":"Name already exists"}', "name_exists"],
+ ['"Email already exists"', "email_exists"],
+ ])("unwraps %s", (body, expected) => {
+ // Hono's bare `HTTPException` answers with the message as plain text, but
+ // VitNode's other conflict routes answer with JSON - both are recognised so
+ // a change on the API's side does not silently degrade to a toast.
+ expect(signUpConflictReason(body)).toBe(expected);
+ });
+
+ it.each([
+ "",
+ "Something else went wrong",
+ "Name code already exists",
+ "{}",
+ "[1,2,3]",
+ "not json {",
+ ])("reads %s as unknown rather than guessing a field", body => {
+ expect(signUpConflictReason(body)).toBe("unknown");
+ });
+});
+
+describe("what a submit result means for the screen", () => {
+ it("says nothing on success, which is how the form knows the caller is leaving", () => {
+ expect(signUpFormOutcome(undefined)).toBeNull();
+ });
+
+ it("swaps the card for the confirmation screen, carrying the address", () => {
+ expect(signUpFormOutcome({ emailConfirmation: "test@test.com" })).toEqual({
+ email: "test@test.com",
+ kind: "confirmation",
+ });
+ });
+
+ it.each([
+ ["email_exists", "email"],
+ ["name_exists", "name"],
+ ] as const)("marks the %s field", (message, field) => {
+ expect(signUpFormOutcome({ message })).toEqual({ field, kind: "field" });
+ });
+
+ it("renders anything else as the internal-error toast", () => {
+ expect(signUpFormOutcome({ message: "Internal Server Error" })).toEqual({
+ kind: "toast",
+ });
+ });
+});
diff --git a/packages/vitnode/src/views/auth/sign-up/form/schema.ts b/packages/vitnode/src/views/auth/sign-up/form/schema.ts
new file mode 100644
index 000000000..f422034d3
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/form/schema.ts
@@ -0,0 +1,230 @@
+import { z } from "zod";
+
+/**
+ * The registration form's shape and its failure vocabulary, with no React in
+ * sight.
+ *
+ * The same split `sign-in/form/schema.ts` makes, for the same reason: the schema
+ * is a function of already-translated strings, and the outcome mapping is a
+ * function of whatever the submit callback returned. Neither needs a renderer, a
+ * provider or a request to be checked - which matters more here than on the
+ * login form, because registration has four outcomes rather than two and one of
+ * them replaces the whole page.
+ */
+
+/** The password rules, as messages rather than as copy. */
+export interface PasswordFieldMessages {
+ /** Shown when the field is missing entirely. */
+ fieldRequired: string;
+ /** Shown for a password that fails any of the four character rules. */
+ invalidPassword: string;
+}
+
+export interface SignUpFormMessages extends PasswordFieldMessages {
+ /** Shown when the email field is not an email address. */
+ invalidEmail: string;
+ /** Shown when the username is longer than 32 characters. */
+ nameMaxLength: string;
+ /** Shown when the username is shorter than 3 characters. */
+ nameMinLength: string;
+ /** Shown when the terms checkbox is left unticked. */
+ termsRequired: string;
+}
+
+/**
+ * The password field, shared by registration and password recovery.
+ *
+ * Four separate `.regex()` calls carrying the *same* message, which is
+ * deliberate: `PasswordInput` renders a live checklist of the four rules from
+ * its own copies of these expressions, so the message a failing password
+ * produces is always "too weak" and the checklist is what says which rule.
+ * Collapsing them into one expression would change nothing on screen and lose
+ * the ability to say which rule a value breaks.
+ *
+ * The API is stricter than this only in that it accepts *less*: `zodSignUpSchema`
+ * asks for eight characters and nothing else, so every value this schema admits
+ * is one the API admits too.
+ */
+export const createPasswordZodSchema = ({
+ fieldRequired,
+ invalidPassword,
+}: PasswordFieldMessages) =>
+ z
+ .string({ message: fieldRequired })
+ .regex(/^.{8,}$/, invalidPassword)
+ .regex(/[A-Z]/, invalidPassword)
+ .regex(/\d/, invalidPassword)
+ .regex(/\W|_/, invalidPassword)
+ .default("");
+
+export const createSignUpFormSchema = ({
+ fieldRequired,
+ invalidEmail,
+ invalidPassword,
+ nameMaxLength,
+ nameMinLength,
+ termsRequired,
+}: SignUpFormMessages) =>
+ z.object({
+ email: z.email({ message: invalidEmail }),
+ name: z
+ .string({ message: fieldRequired })
+ .min(3, nameMinLength)
+ .max(32, nameMaxLength)
+ .default(""),
+ newsletter: z.boolean().default(false).optional(),
+ password: createPasswordZodSchema({ fieldRequired, invalidPassword }),
+ // Never sent to the API - it has no `terms` field. The tick is a local
+ // precondition, which is why it lives in the form schema and is dropped by
+ // the submit callback.
+ terms: z
+ .boolean()
+ .refine(value => value, termsRequired)
+ .default(false),
+ });
+
+export type SignUpFormSchema = ReturnType;
+export type SignUpFormValues = z.infer;
+
+/**
+ * What registration sends, once the form has dropped the parts the API has no
+ * field for.
+ *
+ * `terms` is absent on purpose - the tick is a local precondition, not something
+ * the API stores - and `captchaToken` is present because the sign-up route is
+ * `withCaptcha: true`, so a caller that could not attach one has nothing to
+ * send. Both are the reason this is its own type rather than
+ * {@link SignUpFormValues}.
+ */
+export interface SignUpSubmitValues {
+ captchaToken: string;
+ email: string;
+ name: string;
+ newsletter?: boolean;
+ password: string;
+}
+
+/**
+ * What the API told us about a registration attempt, as the UI cares about it.
+ *
+ * Four outcomes, because registration genuinely has four:
+ *
+ * - `undefined` - it worked *and* the caller has already navigated. The account
+ * was created with `emailVerified: true`, the API minted a session on the same
+ * response, and there is nothing left for the form to render.
+ * - `{ emailConfirmation }` - it worked and the visitor is *not* signed in: this
+ * deployment has an email adapter, so the account waits on a confirmation
+ * link. The address travels back because the confirmation screen prints it.
+ * - `{ message: 'email_exists' | 'name_exists' }` - a conflict the visitor can
+ * fix, and the two are distinguished because they mark different fields.
+ * - `{ message: 'Internal Server Error' }` - anything else, rendered as the
+ * internal-error toast.
+ *
+ * Spelled as literals the transport can produce rather than as the API's own
+ * body, so no backend string reaches a screen: the API answers a 409 with
+ * `"Email already exists"`, and classifying that text is the transport's job.
+ */
+export type SignUpMutationResult =
+ | undefined
+ | { emailConfirmation: string; message?: never }
+ | {
+ emailConfirmation?: never;
+ message: "email_exists" | "Internal Server Error" | "name_exists";
+ };
+
+/** Which field a conflict belongs to. */
+export type SignUpConflictField = "email" | "name";
+
+/**
+ * What a submit result means for the screen.
+ *
+ * - `"confirmation"` - swap the card for the "check your email" view.
+ * - `"field"` - mark one field and focus it; the hook supplies the message,
+ * because it is the half that has translations.
+ * - `"toast"` - the internal-error toast.
+ * - `null` - nothing to show: it worked and the caller navigated.
+ *
+ * A success is deliberately indistinguishable from "returned nothing", exactly
+ * as on the login form: both the Next.js server action and a TanStack Start
+ * mutation leave the page on the happy path, so the resolved value is
+ * `undefined` in both.
+ */
+export const signUpFormOutcome = (
+ result: SignUpMutationResult,
+):
+ | null
+ | { email: string; kind: "confirmation" }
+ | { field: SignUpConflictField; kind: "field" }
+ | { kind: "toast" } => {
+ if (!result) return null;
+
+ if (result.emailConfirmation) {
+ return { email: result.emailConfirmation, kind: "confirmation" };
+ }
+
+ if (result.message === "email_exists") {
+ return { field: "email", kind: "field" };
+ }
+ if (result.message === "name_exists") return { field: "name", kind: "field" };
+
+ return { kind: "toast" };
+};
+
+/** The message inside an API error body, whatever it was wrapped in. */
+const unwrapApiMessage = (body: string): string => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ return body;
+ }
+
+ if (typeof parsed === "string") return parsed;
+ if (typeof parsed !== "object" || parsed === null) return body;
+
+ const { error, message } = parsed as {
+ error?: unknown;
+ message?: unknown;
+ };
+
+ if (typeof error === "string") return error;
+ if (typeof message === "string") return message;
+
+ return body;
+};
+
+/**
+ * Which unique constraint a `409` hit, or `"unknown"`.
+ *
+ * The API answers a conflict with a bare `HTTPException`, whose body is the
+ * message and nothing else - `"Email already exists"` or `"Name already
+ * exists"` (`api/models/user/sign-up.ts`). Two things follow, and this function
+ * is where both are handled:
+ *
+ * 1. **The distinction is worth keeping.** They mark different fields, and the
+ * visitor's next move differs - pick another address, or pick another name.
+ * 2. **The string itself must not travel.** It is an internal message in a fixed
+ * language, so it is classified here and never forwarded; a body that matches
+ * neither becomes `"unknown"` and the caller renders its generic failure
+ * rather than printing something a backend wrote.
+ *
+ * Lives with the schema, framework-free, because both transports have to make
+ * the identical judgement: the Next.js server action reads `res.text()`, and the
+ * TanStack Start server function reads the same body off the same route. One
+ * classifier rather than two that can drift.
+ *
+ * Tolerant about *packaging* and strict about content: a body may arrive as
+ * plain text, as a JSON string, or as `{ "error": ... }` / `{ "message": ... }`
+ * (which is how VitNode's other conflict routes answer), and only the two known
+ * sentences are recognised once unwrapped.
+ */
+export const signUpConflictReason = (
+ body: string,
+): "email_exists" | "name_exists" | "unknown" => {
+ const text = unwrapApiMessage(body).trim().toLowerCase();
+
+ if (text === "email already exists") return "email_exists";
+ if (text === "name already exists") return "name_exists";
+
+ return "unknown";
+};
diff --git a/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx
new file mode 100644
index 000000000..e05dd5e84
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import type { z } from "zod";
+
+import { useTranslations } from "use-intl";
+
+import type { routeMiddlewareSchema } from "@/api/modules/middleware/route";
+
+import {
+ AutoForm,
+ type ItemAutoFormComponentProps,
+} from "@/components/form/auto-form";
+import { AutoFormCheckbox } from "@/components/form/fields/checkbox";
+import { AutoFormInput } from "@/components/form/fields/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import { removeSpecialCharacters } from "@/lib/special-characters";
+
+import type { AuthLinkComponent } from "../../auth-link";
+
+import { PasswordInput } from "../components/password-input";
+import { type SignUpSubmit, useSignUpForm } from "./use-sign-up-form";
+
+export type { SignUpSubmit };
+
+/**
+ * The registration fields, their validation and their failure states - shared.
+ *
+ * Everything that used to be Next-only here has become a prop. The form no
+ * longer imports a server action or `@/lib/navigation`: it is handed
+ * {@link SignUpSubmit} 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 live user-code preview under the username, the
+ * password checklist tooltip, the captcha widget, and the newsletter checkbox
+ * that only appears on a deployment with an email adapter.
+ */
+export const SignUpFormContent = ({
+ captcha,
+ isEmail,
+ LinkComponent,
+ onSignUp,
+ termsHref = "/terms",
+}: {
+ captcha: z.infer["captcha"];
+ /**
+ * Whether this deployment has an email adapter. It decides two things at once:
+ * whether the newsletter checkbox is offered, and - on the API's side - whether
+ * a new account starts verified or waits on a confirmation link.
+ */
+ isEmail: boolean;
+ LinkComponent: AuthLinkComponent;
+ onSignUp: SignUpSubmit;
+ termsHref?: string;
+}) => {
+ const t = useTranslations("core.auth.sign_up");
+ const { formSchema, onSubmit } = useSignUpForm({ onSignUp });
+
+ return (
+ {
+ const value: string = field.value ?? "";
+
+ return (
+
+
+ {value.length >= 3 && (
+
+ {t.rich("username.your_user_code", {
+ code: () => (
+
+ {removeSpecialCharacters(value)}
+
+ ),
+ })}
+
+ )}
+
+ );
+ },
+ },
+ {
+ id: "email",
+ component: props => (
+
+ ),
+ },
+ {
+ id: "password",
+ component: props => (
+
+ ),
+ },
+ {
+ id: "terms",
+ component: props => (
+ (
+
+ {text}
+
+ ),
+ })}
+ label={t("terms.label")}
+ />
+ ),
+ },
+ ...(isEmail
+ ? [
+ {
+ id: "newsletter" as const,
+ component: (props: ItemAutoFormComponentProps) => (
+
+ ),
+ },
+ ]
+ : []),
+ ]}
+ formSchema={formSchema}
+ mode="all"
+ onSubmit={onSubmit}
+ submitButtonProps={{
+ className: "w-full",
+ children: t("submit"),
+ }}
+ />
+ );
+};
+
+/** The form's shape while the deployment configuration is still in flight. */
+export const SignUpFormSkeleton = () => (
+
+ {[0, 1, 2].map(field => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+);
diff --git a/packages/vitnode/src/views/auth/sign-up/form/use-form.ts b/packages/vitnode/src/views/auth/sign-up/form/use-form.ts
deleted file mode 100644
index 5566034ed..000000000
--- a/packages/vitnode/src/views/auth/sign-up/form/use-form.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useTranslations } from "next-intl";
-import { toast } from "sonner";
-import { z } from "zod";
-
-import type { AutoFormOnSubmit } from "@/components/form/auto-form";
-
-import { useWrapperSignUp } from "../wrapper";
-import { mutationApi } from "./mutation-api.server";
-
-export const usePasswordZodSchema = () => {
- const t = useTranslations("core.auth.sign_up");
- const tError = useTranslations("core.global.errors");
- const invalidPassword = t("password.invalid");
-
- return z
- .string({
- message: tError("field_required"),
- })
- .regex(/^.{8,}$/, invalidPassword)
- .regex(/[A-Z]/, invalidPassword)
- .regex(/\d/, invalidPassword)
- .regex(/\W|_/, invalidPassword)
- .default("");
-};
-
-export const useFormSignUp = () => {
- const t = useTranslations("core.auth.sign_up");
- const tError = useTranslations("core.global.errors");
- const passwordSchema = usePasswordZodSchema();
-
- const formSchema = z.object({
- name: z
- .string({
- message: tError("field_required"),
- })
- .min(3, t("username.min_length"))
- .max(32, t("username.max_length"))
- .default(""),
- // .refine(value => nameRegex.test(value), t('name.invalid'))
- email: z.email({
- message: t("email.invalid"),
- }),
- password: passwordSchema,
- terms: z
- .boolean()
- .refine(value => value, t("terms.required"))
- .default(false),
- newsletter: z.boolean().default(false).optional(),
- });
-
- const { setSendingEmail } = useWrapperSignUp();
-
- const onSubmit: AutoFormOnSubmit = async (
- values,
- form,
- { captchaToken },
- ) => {
- const mutation = await mutationApi({ ...values, captchaToken });
- if (mutation.data) {
- if (!mutation.data.emailVerified) {
- setSendingEmail(mutation.data.email);
- }
-
- return;
- }
-
- const errorMessages = {
- "Email already exists": {
- field: "email",
- message: t("email.exists"),
- },
- "Name already exists": {
- field: "name",
- message: t("username.exists"),
- },
- } as const;
-
- const errorConfig =
- errorMessages[mutation.error as unknown as keyof typeof errorMessages];
-
- if (errorConfig) {
- form.setError(
- errorConfig.field,
- {
- type: "manual",
- message: errorConfig.message,
- },
- {
- shouldFocus: true,
- },
- );
-
- return;
- }
-
- toast.error(tError("title"), {
- description: tError("internal_server_error"),
- });
- };
-
- return { onSubmit, formSchema };
-};
diff --git a/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts b/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts
new file mode 100644
index 000000000..9f5529c82
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts
@@ -0,0 +1,109 @@
+"use client";
+
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { AutoFormOnSubmit } from "@/components/form/auto-form";
+
+import type {
+ SignUpFormSchema,
+ SignUpFormValues,
+ SignUpMutationResult,
+ SignUpSubmitValues,
+} from "./schema";
+
+import { useWrapperSignUp } from "../wrapper";
+import { createSignUpFormSchema, signUpFormOutcome } from "./schema";
+
+export type { SignUpSubmitValues };
+
+/**
+ * How the form asks for an account.
+ *
+ * The whole of the framework boundary for registering, and deliberately one
+ * function: it takes the field values and answers what happened, or nothing at
+ * all. What it does on success - copy a session cookie, refresh a cached
+ * session, navigate - is entirely the caller's business, which is why nothing
+ * here handles it. Next.js redirects from a server action; TanStack Start calls
+ * a server function, refreshes the canonical session query and moves the router.
+ */
+export type SignUpSubmit = (
+ values: SignUpSubmitValues,
+) => Promise;
+
+/**
+ * The registration form's behaviour, with no idea which framework is rendering
+ * it.
+ *
+ * `use-intl` rather than `next-intl` for the strings - the same module record
+ * either way - 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.
+ *
+ * ## Where the confirmation screen comes from
+ *
+ * `useWrapperSignUp` - the context {@link WrapperSignUp} mounts, which
+ * {@link SignUpContent} renders for both frameworks. When the account was
+ * created but not verified, this hands it the address and the wrapper swaps the
+ * card for the "check your email" view. Nothing about that is Next-specific,
+ * which is why it stayed a context rather than becoming a fifth prop: the form
+ * is several levels below the component that has to change shape.
+ */
+export const useSignUpForm = ({ onSignUp }: { onSignUp: SignUpSubmit }) => {
+ const t = useTranslations("core.auth.sign_up");
+ const tErrors = useTranslations("core.global.errors");
+ const { setSendingEmail } = useWrapperSignUp();
+
+ const formSchema = createSignUpFormSchema({
+ fieldRequired: tErrors("field_required"),
+ invalidEmail: t("email.invalid"),
+ invalidPassword: t("password.invalid"),
+ nameMaxLength: t("username.max_length"),
+ nameMinLength: t("username.min_length"),
+ termsRequired: t("terms.required"),
+ });
+
+ const onSubmit: AutoFormOnSubmit = async (
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ { terms: _terms, ...values }: SignUpFormValues,
+ form,
+ { captchaToken },
+ ) => {
+ const outcome = signUpFormOutcome(
+ await onSignUp({ ...values, captchaToken }),
+ );
+
+ if (!outcome) return;
+
+ if (outcome.kind === "confirmation") {
+ setSendingEmail(outcome.email);
+
+ return;
+ }
+
+ if (outcome.kind === "field") {
+ form.setError(
+ outcome.field,
+ {
+ type: "manual",
+ message:
+ outcome.field === "email"
+ ? t("email.exists")
+ : t("username.exists"),
+ },
+ { shouldFocus: true },
+ );
+
+ return;
+ }
+
+ toast.error(tErrors("title"), {
+ description: tErrors("internal_server_error"),
+ });
+ };
+
+ return { formSchema, onSubmit };
+};
diff --git a/packages/vitnode/src/views/auth/sign-up/sign-up-card.tsx b/packages/vitnode/src/views/auth/sign-up/sign-up-card.tsx
new file mode 100644
index 000000000..3bcae5b60
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/sign-up-card.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { NextAuthLink } from "../next-link";
+import { SignUpContent } from "./sign-up-content";
+
+/**
+ * {@link SignUpContent}, wired to Next.js.
+ *
+ * A client component with two slots, and that shape is load bearing - the same
+ * arrangement `SignInCard` uses. The card itself has to be one: it reads its
+ * strings from the client context `I18nProvider` mounts, it owns the
+ * confirmation state through `WrapperSignUp`, and a component type such as
+ * `LinkComponent` cannot cross the server/client boundary as a prop.
+ *
+ * `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 `SignUpView`.
+ */
+export const SignUpCard = ({
+ form,
+ sso,
+}: {
+ form: React.ReactNode;
+ sso?: React.ReactNode;
+}) => ;
diff --git a/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx b/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx
new file mode 100644
index 000000000..3338d9366
--- /dev/null
+++ b/packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx
@@ -0,0 +1,82 @@
+"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";
+import { WrapperSignUp } from "./wrapper";
+
+/**
+ * The registration card - the heading, the copy, and the two slots that fill it.
+ *
+ * The counterpart of `SignInContent`, and framework-free for the same reason: 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
+ * ``; a TanStack Start route has the same data from its loader before
+ * this renders at all, and passes the finished elements.
+ *
+ * ## Why the wrapper is inside
+ *
+ * {@link WrapperSignUp} is here rather than left to each caller because the
+ * "check your email" screen *replaces this card*, and a caller that forgot to
+ * mount it would get a form that succeeds and then appears to do nothing. It is
+ * ordinary client React - `useState` and a context - so both frameworks mount
+ * the same one, and the confirmation state lives exactly one level above the
+ * thing it hides.
+ */
+export const SignUpContent = ({
+ form,
+ LinkComponent,
+ signInHref = AUTH_HREF.signIn,
+ sso,
+}: {
+ form: React.ReactNode;
+ LinkComponent: AuthLinkComponent;
+ signInHref?: string;
+ sso?: React.ReactNode;
+}) => {
+ const t = useTranslations("core.auth.sign_up");
+ const tGlobal = useTranslations("core.global");
+
+ return (
+
+
+
+
+
+
+ {tGlobal("register")}
+
+ {t("desc")}
+
+
+ {form}
+
+ {sso}
+
+
+
+ {t.rich("already_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 afff4b49d..a1c93e266 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
@@ -1,80 +1,43 @@
-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 { FormSignUp } from "./form/form";
-import { WrapperSignUp } from "./wrapper";
+import { SignUpFormSkeleton } from "./form/sign-up-form-content";
+import { SignUpCard } from "./sign-up-card";
const SignUpForm = async () => {
- const { isEmail, captcha } = await getMiddlewareApi();
+ const { captcha, isEmail } = await getMiddlewareApi();
return ;
};
-const SignUpFormSkeleton = () => (
-
- {[0, 1, 2].map(field => (
-
-
-
-
- ))}
-
-
-
-
-
-
-
-
+/**
+ * The registration page for Next.js.
+ *
+ * Everything visible is `SignUpContent`, 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, whether an email adapter
+ * exists, and the public captcha key. 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 SignUpView = () => (
+
+ }>
+
+
+ }
+ sso={
+ }>
+
+
+ }
+ />
+
);
-
-export const SignUpView = async () => {
- const [t, tGlobal] = await Promise.all([
- getTranslations("core.auth.sign_up"),
- getTranslations("core.global"),
- ]);
-
- return (
-
-
-
-
-
-
-
- {tGlobal("register")}
-
- {t("desc")}
-
-
- }>
-
-
-
- }>
-
-
-
-
-
- {t.rich("already_have_account", {
- link: text => (
-
- {text}
-
- ),
- })}
-
-
-
-
-
- );
-};
diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx
new file mode 100644
index 000000000..e24e66b34
--- /dev/null
+++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx
@@ -0,0 +1,42 @@
+import type { AuthLinkComponent } from "../auth/auth-link";
+
+import { BreadcrumbRenderContent } from "./breadcrumb-render-content";
+import { resolveMainBreadcrumb } from "./resolve-main-breadcrumb";
+
+export interface BreadcrumbMainContentProps {
+ labels?: Record;
+ LinkComponent: AuthLinkComponent;
+ overrideLastLabel?: string;
+ segments: string[];
+}
+
+/**
+ * The public site's breadcrumb, framework-free.
+ *
+ * The same two steps `BreadcrumbMain` has always taken - path segments into
+ * crumbs, crumbs into markup - with the link handed in rather than imported. The
+ * container is here rather than at each call site so both frameworks get the
+ * same spacing: Next.js renders this into the `@breadcrumb` parallel slot,
+ * TanStack Start into the shell's breadcrumb area through
+ * `staticData.breadcrumb`.
+ */
+export const BreadcrumbMainContent = ({
+ labels,
+ LinkComponent,
+ overrideLastLabel,
+ segments,
+}: BreadcrumbMainContentProps) => {
+ const crumbs = resolveMainBreadcrumb(segments, labels);
+
+ if (crumbs.length === 0) return null;
+
+ if (overrideLastLabel) {
+ crumbs[crumbs.length - 1].label = overrideLastLabel;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx
index a58eb2c78..b43b3c390 100644
--- a/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx
+++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-main.tsx
@@ -1,28 +1,15 @@
-import { BreadcrumbRender } from "./breadcrumb-render";
-import { resolveMainBreadcrumb } from "./resolve-main-breadcrumb";
+import { Link } from "@/lib/navigation";
-export interface BreadcrumbMainProps {
- labels?: Record;
- overrideLastLabel?: string;
- segments: string[];
-}
+import type { BreadcrumbMainContentProps } from "./breadcrumb-main-content";
-export const BreadcrumbMain = ({
- segments,
- labels,
- overrideLastLabel,
-}: BreadcrumbMainProps) => {
- const crumbs = resolveMainBreadcrumb(segments, labels);
+import { BreadcrumbMainContent } from "./breadcrumb-main-content";
- if (crumbs.length === 0) return null;
+export type BreadcrumbMainProps = Omit<
+ BreadcrumbMainContentProps,
+ "LinkComponent"
+>;
- if (overrideLastLabel) {
- crumbs[crumbs.length - 1].label = overrideLastLabel;
- }
-
- return (
-
-
-
- );
-};
+/** {@link BreadcrumbMainContent}, wired to `next-intl`'s locale-aware `Link`. */
+export const BreadcrumbMain = (props: BreadcrumbMainProps) => (
+
+);
diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx
new file mode 100644
index 000000000..5ab751267
--- /dev/null
+++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx
@@ -0,0 +1,78 @@
+import { Fragment } from "react";
+
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import { cn } from "@/lib/utils";
+
+import type { AuthLinkComponent } from "../auth/auth-link";
+import type { BreadcrumbCrumb } from "./crumb";
+
+/**
+ * A breadcrumb trail, with the one thing it cannot decide for itself handed in.
+ *
+ * Turning `/settings` into a navigation is the only framework-specific part of a
+ * breadcrumb: Next.js wants `next-intl`'s locale-aware `Link`
+ * (`@/lib/navigation`), TanStack Start wants the router's own. Both are a
+ * component taking an anchor's props, so this takes one and stops caring - and
+ * importing neither is what lets a TanStack Start route render the same trail
+ * the Next.js `@breadcrumb` slot renders.
+ *
+ * `AuthLinkComponent` is reused rather than redeclared: it is already "every prop
+ * of an anchor, plus a required `href`", which is exactly what a crumb needs and
+ * what `MigrationLink` in `apps/web` already satisfies.
+ *
+ * Deliberately not a client component. It renders no hooks, and Next.js passes
+ * `LinkComponent` into it from a Server Component - a boundary here would turn
+ * that prop into something that cannot cross it.
+ */
+export const BreadcrumbRenderContent = ({
+ crumbs,
+ LinkComponent,
+ scrollable,
+}: {
+ crumbs: BreadcrumbCrumb[];
+ LinkComponent: AuthLinkComponent;
+ scrollable?: boolean;
+}) => {
+ if (crumbs.length === 0) return null;
+
+ return (
+
+
+ {crumbs.map((crumb, index) => (
+
+ {index > 0 && }
+
+ {crumb.isCurrent ? (
+ {crumb.label}
+ ) : crumb.isLink ? (
+
+ {crumb.label}
+
+ }
+ />
+ ) : (
+ {crumb.label}
+ )}
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx
index 061265eb3..44ab99cdd 100644
--- a/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx
+++ b/packages/vitnode/src/views/breadcrumb/breadcrumb-render.tsx
@@ -3,56 +3,35 @@ import { Fragment } from "react";
import {
Breadcrumb,
BreadcrumbItem,
- BreadcrumbLink,
BreadcrumbList,
- BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Skeleton } from "@/components/ui/skeleton";
import { Link } from "@/lib/navigation";
-import { cn } from "@/lib/utils";
import type { BreadcrumbCrumb } from "./crumb";
+import { BreadcrumbRenderContent } from "./breadcrumb-render-content";
+
+/**
+ * {@link BreadcrumbRenderContent}, wired to Next.js.
+ *
+ * Where `next-intl`'s locale-aware `Link` enters a breadcrumb, and the only
+ * place it does - the AdminCP trail and the public one both render through here.
+ */
export const BreadcrumbRender = ({
crumbs,
scrollable,
}: {
crumbs: BreadcrumbCrumb[];
scrollable?: boolean;
-}) => {
- if (crumbs.length === 0) return null;
-
- return (
-
-
- {crumbs.map((crumb, index) => (
-
- {index > 0 && }
-
- {crumb.isCurrent ? (
- {crumb.label}
- ) : crumb.isLink ? (
- {crumb.label}}
- />
- ) : (
- {crumb.label}
- )}
-
-
- ))}
-
-
- );
-};
+}) => (
+
+);
export const BreadcrumbSkeleton = ({ crumbs = 2 }: { crumbs?: number }) => (
diff --git a/packages/vitnode/src/views/files/my-files-query.test.ts b/packages/vitnode/src/views/files/my-files-query.test.ts
index 51020a4d3..eaa504cde 100644
--- a/packages/vitnode/src/views/files/my-files-query.test.ts
+++ b/packages/vitnode/src/views/files/my-files-query.test.ts
@@ -1,3 +1,4 @@
+import { hashKey } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { BulkDeleteFilesResult } from "@/lib/files/bulk-delete";
@@ -12,8 +13,8 @@ import {
describeMyFilesParams,
isMyFilesRequestError,
MY_FILES_MAX_PAGE_SIZE,
- MY_FILES_QUERY_ROOT,
myFilesQueryKey,
+ myFilesQueryRoot,
myFilesRequest,
MyFilesRequestError,
normalizeMyFilesParams,
@@ -167,20 +168,22 @@ describe("myFilesRequest", () => {
});
describe("myFilesQueryKey", () => {
- it("hangs off the root an invalidation can name", () => {
- expect(myFilesQueryKey(normalizeMyFilesParams()).slice(0, 2)).toEqual([
- ...MY_FILES_QUERY_ROOT,
- ]);
+ const keyFor = (
+ userId: number,
+ raw?: Parameters[0],
+ ) => myFilesQueryKey({ params: normalizeMyFilesParams(raw), userId });
+
+ it("hangs off the owner's own root, which an invalidation can name", () => {
+ expect(keyFor(10).slice(0, 3)).toEqual([...myFilesQueryRoot(10)]);
+ expect(myFilesQueryRoot(10)).toEqual(["files", "user", 10]);
});
it("is the same key for two spellings of the same request", () => {
- expect(myFilesQueryKey(normalizeMyFilesParams({ search: "" }))).toEqual(
- myFilesQueryKey(normalizeMyFilesParams({ first: "10" })),
- );
+ expect(keyFor(10, { search: "" })).toEqual(keyFor(10, { first: "10" }));
});
it("is a different key for everything that changes the rows", () => {
- const base = myFilesQueryKey(normalizeMyFilesParams());
+ const base = keyFor(10);
const differing = [
{ first: "40" },
{ cursor: "abc" },
@@ -191,16 +194,64 @@ describe("myFilesQueryKey", () => {
];
for (const raw of differing) {
- expect(myFilesQueryKey(normalizeMyFilesParams(raw))).not.toEqual(base);
+ expect(keyFor(10, raw)).not.toEqual(base);
}
});
+ /**
+ * The privacy invariant, as the key contract rather than as a browser test.
+ *
+ * The browser's `QueryClient` is created once per document and outlives a
+ * sign-out, so one document can hold two visitors. Under the
+ * `["files", "me", params]` this replaces, B's loader asked for the entry A
+ * had already filled - and with `refetchOnMount` and `refetchOnWindowFocus`
+ * both off, nothing refetched it. No request was made, so Hono never saw the
+ * read it would have refused, and B was shown A's file names.
+ */
+ it("gives two visitors two keys for identical parameters", () => {
+ const params = normalizeMyFilesParams({ first: "10" });
+
+ expect(myFilesQueryKey({ params, userId: 10 })).not.toEqual(
+ myFilesQueryKey({ params, userId: 20 }),
+ );
+ expect(hashKey(myFilesQueryKey({ params, userId: 10 }))).not.toBe(
+ hashKey(myFilesQueryKey({ params, userId: 20 })),
+ );
+ });
+
+ it("keeps one visitor's pages, sorts and searches under one root", () => {
+ // What a delete invalidates: the family, so every page and sort of *this*
+ // visitor's files goes stale rather than only the one on screen.
+ const root = myFilesQueryRoot(10);
+
+ for (const raw of [
+ { cursor: "abc" },
+ { orderBy: "name" },
+ { search: "x" },
+ ]) {
+ expect(keyFor(10, raw).slice(0, root.length)).toEqual([...root]);
+ }
+ });
+
+ it("puts another visitor outside that root, so a delete cannot reach them", () => {
+ // Query matches by prefix, so this is the whole of "invalidate only mine".
+ const root = myFilesQueryRoot(10);
+
+ expect(keyFor(20).slice(0, root.length)).not.toEqual([...root]);
+ });
+
it("does not vary by language, because the rows do not", () => {
// Only the column headings are translated, and the renderer resolves those.
// A locale in the key would refetch an identical list on every switch.
+ expect(JSON.stringify(keyFor(10))).not.toContain("locale");
+ });
+
+ it("sends no owner to the API, which reads it from the session cookie", () => {
+ // The id addresses a cache slot. If it reached the wire it would become an
+ // access-control parameter the browser supplies.
expect(
- JSON.stringify(myFilesQueryKey(normalizeMyFilesParams())),
- ).not.toContain("locale");
+ myFilesRequest(normalizeMyFilesParams()).args.query,
+ ).not.toHaveProperty("userId");
});
});
diff --git a/packages/vitnode/src/views/files/my-files-query.ts b/packages/vitnode/src/views/files/my-files-query.ts
index 817f1fd78..88ad2cd5e 100644
--- a/packages/vitnode/src/views/files/my-files-query.ts
+++ b/packages/vitnode/src/views/files/my-files-query.ts
@@ -303,19 +303,45 @@ export const fetchMyFilesPageInBrowser: MyFilesPageFetcher = async params => {
};
/**
- * The root every cache entry for this list hangs off.
+ * The root every cache entry for one visitor's files hangs off.
*
- * Exported so an invalidation can name the whole family - one delete makes every
- * page, sort and search of the visitor's own files stale, not just the one they
- * are looking at. TanStack Query matches keys by prefix, so this invalidates
- * exactly those and nothing else.
+ * A factory over the owner's id rather than the constant `["files", "me"]` it
+ * replaces, and the difference is a privacy one rather than a tidiness one.
+ *
+ * ## Why `"me"` was unsafe
+ *
+ * `"me"` is only stable for as long as "me" is. The browser's `QueryClient` is
+ * created once per document and outlives a sign-out, so one browser can hold two
+ * visitors in one session:
+ *
+ * A signs in -> /files -> ["files","me",params] holds A's file names
+ * A signs out
+ * B signs in -> /files -> the loader asks for ["files","me",params]
+ *
+ * and that entry is already populated. With `refetchOnMount` and
+ * `refetchOnWindowFocus` both off in VitNode's client defaults, nothing would
+ * have refetched it, so B would read A's private data with no API request made
+ * at all - which is exactly why Hono cannot defend against it. There is no
+ * request for it to authorize.
+ *
+ * Keyed by owner the two visitors address different entries, B's is empty, the
+ * fetch happens, and the API answers it from B's own session cookie.
+ *
+ * ## The id is a cache address, never a claim
+ *
+ * Nothing about this reaches the network. {@link myFilesRequest} takes no owner
+ * and `GET /users/files` derives it from the session cookie, exactly as before -
+ * so a tampered id partitions a cache differently and authorizes nothing. Were
+ * it ever sent, this would stop being a cache key and become an access-control
+ * parameter, which is the one thing it must not be.
*/
-export const MY_FILES_QUERY_ROOT = ["files", "me"] as const;
+export const myFilesQueryRoot = (userId: number) =>
+ ["files", "user", userId] as const;
/**
- * The cache entry one page of the list reads and writes.
+ * The cache entry one page of one visitor's list reads and writes.
*
- * The normalised parameters, and only those. Everything that changes which rows
+ * The owner, then the normalised parameters. Everything that changes which rows
* come back is in there - page, size, sort, search - and nothing that does not.
*
* The locale is deliberately absent. File names, folders, sizes and metadata are
@@ -327,23 +353,37 @@ export const MY_FILES_QUERY_ROOT = ["files", "me"] as const;
* An object in a key is safe - Query hashes keys structurally rather than by
* identity - which is exactly why the object has to be the *normalised* one.
*/
-export const myFilesQueryKey = (params: MyFilesParams) =>
- [...MY_FILES_QUERY_ROOT, params] as const;
+export const myFilesQueryKey = ({
+ params,
+ userId,
+}: {
+ params: MyFilesParams;
+ userId: number;
+}) => [...myFilesQueryRoot(userId), params] as const;
/**
* The visitor's files, as the one query definition every caller shares.
*
* A route loader warms it before the component renders:
*
- * context.queryClient.ensureQueryData(myFilesQueryOptions({ params }))
+ * context.queryClient.ensureQueryData(
+ * myFilesQueryOptions({ params, userId }),
+ * )
*
* and the component reads the very same options back:
*
- * const { data } = useQuery(myFilesQueryOptions({ params }))
+ * const { data } = useQuery(myFilesQueryOptions({ params, userId }))
*
* Same key, same request, same status checking - so the loader's page is the
* page the component renders, and a delete that invalidates
- * {@link MY_FILES_QUERY_ROOT} refetches through the identical contract.
+ * {@link myFilesQueryRoot} refetches through the identical contract.
+ *
+ * `userId` addresses the cache and nothing else - see {@link myFilesQueryRoot}.
+ * It is required rather than defaulted because there is no honest default: a
+ * fallback would be one shared entry again, which is the bug the parameter
+ * exists to close. Both callers take it from the one place that knows it, the
+ * `_authenticated` route context, so the loader and the component cannot drift
+ * onto two different partitions.
*
* `fetchPage` is the seam. It defaults to the browser's fetcher, which is what a
* hydrated page wants; an app that also fetches during SSR passes one that can
@@ -368,13 +408,17 @@ export const myFilesQueryKey = (params: MyFilesParams) =>
export const myFilesQueryOptions = ({
fetchPage = fetchMyFilesPageInBrowser,
params,
+ userId,
}: {
fetchPage?: MyFilesPageFetcher;
params: MyFilesParams;
+ userId: number;
}) =>
queryOptions({
+ // `userId` is deliberately absent from the request: the owner comes from
+ // the session cookie, on the server, on every call.
queryFn: async () => await fetchPage(params),
- queryKey: myFilesQueryKey(params),
+ queryKey: myFilesQueryKey({ params, userId }),
retry: false,
});
diff --git a/packages/vitnode/src/views/layouts/rate-limit-listener.tsx b/packages/vitnode/src/views/layouts/rate-limit-listener.tsx
index 8c7a755bb..4f2b7bbc3 100644
--- a/packages/vitnode/src/views/layouts/rate-limit-listener.tsx
+++ b/packages/vitnode/src/views/layouts/rate-limit-listener.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useTranslations } from "next-intl";
import React from "react";
import { toast } from "sonner";
+import { useTranslations } from "use-intl";
import {
RATE_LIMIT_EVENT,
diff --git a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
index 52b2f0d57..9622e315c 100644
--- a/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
+++ b/packages/vitnode/src/views/layouts/theme/header/user/user-header-model.ts
@@ -94,11 +94,17 @@ export type UserHeaderState =
* Where the header links to.
*
* Ordinary data, not a route table - nothing here knows or cares which
- * application currently serves a path. During the migration `/files` and
- * `/login` are rendered by TanStack Start and `/settings`, `/register`,
- * `/admin` and the profile page by Next.js, and the *link component* is what
- * decides that, per href, by asking the route tree. So a route that moves needs
- * no edit here.
+ * application currently serves a path. During the migration some of these are
+ * rendered by TanStack Start and some still by Next.js, and the *link component*
+ * is what decides which, per href, by asking the route tree. So a route that
+ * moves needs no edit here.
+ *
+ * That is a claim worth having been tested rather than asserted, and it has
+ * been: `/settings` and `/register` were Next.js pages when this record was
+ * written and are TanStack Start routes now, and the change that moved them
+ * added route files and touched neither this file nor `MigrationLink`. The
+ * AdminCP and the profile page are still the other application's.
+ * `apps/web/src/tests/header-navigation.test.ts` pins both halves.
*/
export const USER_HEADER_HREF = {
adminCp: "/admin",