From 3f37ce58bca63cf1805efe00e8b1143bd0920467 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 28 Aug 2026 17:29:13 +0200 Subject: [PATCH 1/3] feat: Add settings in tanstack start --- apps/web/src/components/error-actions.tsx | 55 +++ .../components/layout/settings-breadcrumb.tsx | 78 +++++ apps/web/src/components/migration-link.tsx | 22 +- .../web/src/components/realtime-listeners.tsx | 15 + apps/web/src/lib/auth/actions.ts | 124 ++++++- apps/web/src/lib/auth/contract.ts | 288 ++++++++++++++++ apps/web/src/lib/auth/mutations.ts | 54 +++ apps/web/src/lib/auth/password-reset-route.ts | 166 ++++++++++ apps/web/src/lib/auth/query.ts | 86 ++++- apps/web/src/lib/auth/screens.ts | 87 +++++ apps/web/src/lib/devices/devices.ts | 144 ++++++++ apps/web/src/lib/middleware-config.ts | 11 + apps/web/src/lib/settings/panel.ts | 164 +++++++++ apps/web/src/routeTree.gen.ts | 185 +++++++++-- apps/web/src/routes/_main.tsx | 24 +- apps/web/src/routes/_main/_authenticated.tsx | 13 +- .../routes/_main/_authenticated/account.tsx | 103 ------ .../routes/_main/_authenticated/settings.tsx | 166 ++++++++++ .../_main/_authenticated/settings/devices.tsx | 159 +++++++++ .../_main/_authenticated/settings/index.tsx | 38 +++ .../_authenticated/settings/overview.tsx | 26 ++ .../_authenticated/settings/security.tsx | 25 ++ apps/web/src/routes/login.tsx | 23 +- apps/web/src/routes/login_.reset-password.tsx | 266 +++++++++++++++ .../web/src/routes/login_.sso.$providerId.tsx | 54 +-- apps/web/src/routes/register.tsx | 228 +++++++++++++ apps/web/src/server/auth.server.ts | 164 +++++++++ apps/web/src/server/devices.server.ts | 45 +++ apps/web/src/tests/auth-routes.test.ts | 262 +++++++++++++++ apps/web/src/tests/devices-route.test.ts | 169 ++++++++++ apps/web/src/tests/header-navigation.test.ts | 88 +++++ apps/web/src/tests/isolation.test.ts | 22 +- apps/web/src/tests/main-shell.test.ts | 41 ++- apps/web/src/tests/plugin-routes.test.ts | 88 +++-- apps/web/src/tests/recovery-contract.test.ts | 166 ++++++++++ .../src/tests/registration-contract.test.ts | 201 +++++++++++ .../src/tests/registration-screens.test.ts | 106 ++++++ apps/web/src/tests/session-query.test.ts | 155 ++++++++- apps/web/src/tests/settings-routes.test.ts | 312 ++++++++++++++++++ .../users/routes/change-password.route.ts | 9 + .../vitnode/src/lib/api/get-devices-api.ts | 16 - .../src/views/auth/auth-boundaries.test.ts | 166 +++++++++- packages/vitnode/src/views/auth/auth-link.ts | 10 +- .../change-password-form-content.tsx | 86 +++++ .../change-password-form/form.tsx | 66 ++-- .../mutation-api.server.ts | 30 +- .../change-password-form/schema.test.ts | 50 +++ .../change-password-form/schema.ts | 77 +++++ .../use-change-password-form.ts | 101 ++++++ .../change-password-form/use-form.ts | 50 --- .../views/auth/password-reset/form/form.tsx | 98 +----- .../form/mutation-api.server.ts | 30 +- .../form/password-reset-form-content.tsx | 121 +++++++ .../auth/password-reset/form/schema.test.ts | 45 +++ .../views/auth/password-reset/form/schema.ts | 66 ++++ .../auth/password-reset/form/use-form.ts | 41 --- .../form/use-password-reset-form.ts | 78 +++++ .../password-reset/password-reset-content.tsx | 39 +++ .../password-reset/password-reset-view.tsx | 60 ++-- .../auth/password-reset/recovery-link.test.ts | 74 +++++ .../auth/password-reset/recovery-link.ts | 83 +++++ .../auth/settings/devices/device-item.tsx | 73 ++-- .../devices/devices-boundaries.test.ts | 257 +++++++++++++++ .../auth/settings/devices/devices-content.tsx | 60 ++++ .../auth/settings/devices/devices-list.tsx | 54 ++- .../settings/devices/devices-query.test.ts | 216 ++++++++++++ .../auth/settings/devices/devices-query.ts | 245 ++++++++++++++ .../auth/settings/devices/devices-revoke.ts | 210 ++++++++++++ .../settings/devices/revoke-action.server.ts | 58 ++-- .../settings/devices/revoke-device-button.tsx | 31 +- .../src/views/auth/settings/nav-content.tsx | 73 ++++ .../vitnode/src/views/auth/settings/nav.tsx | 70 +--- .../views/auth/settings/overview/overview.tsx | 24 +- .../views/auth/settings/security/security.tsx | 20 +- .../src/views/auth/settings/settings-nav.ts | 98 ++++++ .../src/views/auth/settings/shell-content.tsx | 98 ++++++ .../vitnode/src/views/auth/settings/shell.tsx | 77 ++--- .../src/views/auth/sign-in/form/form.tsx | 7 +- .../src/views/auth/sign-in/sign-in-card.tsx | 5 +- .../sign-up/components/password-input.tsx | 2 +- .../auth/sign-up/email-confirmation-view.tsx | 2 +- .../src/views/auth/sign-up/form/form.tsx | 129 ++------ .../auth/sign-up/form/mutation-api.server.ts | 48 ++- .../views/auth/sign-up/form/schema.test.ts | 173 ++++++++++ .../src/views/auth/sign-up/form/schema.ts | 230 +++++++++++++ .../sign-up/form/sign-up-form-content.tsx | 161 +++++++++ .../src/views/auth/sign-up/form/use-form.ts | 102 ------ .../auth/sign-up/form/use-sign-up-form.ts | 109 ++++++ .../src/views/auth/sign-up/sign-up-card.tsx | 25 ++ .../views/auth/sign-up/sign-up-content.tsx | 82 +++++ .../src/views/auth/sign-up/sign-up-view.tsx | 95 ++---- .../breadcrumb/breadcrumb-main-content.tsx | 42 +++ .../src/views/breadcrumb/breadcrumb-main.tsx | 35 +- .../breadcrumb/breadcrumb-render-content.tsx | 78 +++++ .../views/breadcrumb/breadcrumb-render.tsx | 51 +-- .../theme/header/user/user-header-model.ts | 16 +- 96 files changed, 7771 insertions(+), 1104 deletions(-) create mode 100644 apps/web/src/components/error-actions.tsx create mode 100644 apps/web/src/components/layout/settings-breadcrumb.tsx create mode 100644 apps/web/src/lib/auth/password-reset-route.ts create mode 100644 apps/web/src/lib/devices/devices.ts create mode 100644 apps/web/src/lib/settings/panel.ts delete mode 100644 apps/web/src/routes/_main/_authenticated/account.tsx create mode 100644 apps/web/src/routes/_main/_authenticated/settings.tsx create mode 100644 apps/web/src/routes/_main/_authenticated/settings/devices.tsx create mode 100644 apps/web/src/routes/_main/_authenticated/settings/index.tsx create mode 100644 apps/web/src/routes/_main/_authenticated/settings/overview.tsx create mode 100644 apps/web/src/routes/_main/_authenticated/settings/security.tsx create mode 100644 apps/web/src/routes/login_.reset-password.tsx create mode 100644 apps/web/src/routes/register.tsx create mode 100644 apps/web/src/server/devices.server.ts create mode 100644 apps/web/src/tests/auth-routes.test.ts create mode 100644 apps/web/src/tests/devices-route.test.ts create mode 100644 apps/web/src/tests/recovery-contract.test.ts create mode 100644 apps/web/src/tests/registration-contract.test.ts create mode 100644 apps/web/src/tests/registration-screens.test.ts create mode 100644 apps/web/src/tests/settings-routes.test.ts delete mode 100644 packages/vitnode/src/lib/api/get-devices-api.ts create mode 100644 packages/vitnode/src/views/auth/password-reset/change-password-form/change-password-form-content.tsx create mode 100644 packages/vitnode/src/views/auth/password-reset/change-password-form/schema.test.ts create mode 100644 packages/vitnode/src/views/auth/password-reset/change-password-form/schema.ts create mode 100644 packages/vitnode/src/views/auth/password-reset/change-password-form/use-change-password-form.ts delete mode 100644 packages/vitnode/src/views/auth/password-reset/change-password-form/use-form.ts create mode 100644 packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.tsx create mode 100644 packages/vitnode/src/views/auth/password-reset/form/schema.test.ts create mode 100644 packages/vitnode/src/views/auth/password-reset/form/schema.ts delete mode 100644 packages/vitnode/src/views/auth/password-reset/form/use-form.ts create mode 100644 packages/vitnode/src/views/auth/password-reset/form/use-password-reset-form.ts create mode 100644 packages/vitnode/src/views/auth/password-reset/password-reset-content.tsx create mode 100644 packages/vitnode/src/views/auth/password-reset/recovery-link.test.ts create mode 100644 packages/vitnode/src/views/auth/password-reset/recovery-link.ts create mode 100644 packages/vitnode/src/views/auth/settings/devices/devices-boundaries.test.ts create mode 100644 packages/vitnode/src/views/auth/settings/devices/devices-content.tsx create mode 100644 packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts create mode 100644 packages/vitnode/src/views/auth/settings/devices/devices-query.ts create mode 100644 packages/vitnode/src/views/auth/settings/devices/devices-revoke.ts create mode 100644 packages/vitnode/src/views/auth/settings/nav-content.tsx create mode 100644 packages/vitnode/src/views/auth/settings/settings-nav.ts create mode 100644 packages/vitnode/src/views/auth/settings/shell-content.tsx create mode 100644 packages/vitnode/src/views/auth/sign-up/form/schema.test.ts create mode 100644 packages/vitnode/src/views/auth/sign-up/form/schema.ts create mode 100644 packages/vitnode/src/views/auth/sign-up/form/sign-up-form-content.tsx delete mode 100644 packages/vitnode/src/views/auth/sign-up/form/use-form.ts create mode 100644 packages/vitnode/src/views/auth/sign-up/form/use-sign-up-form.ts create mode 100644 packages/vitnode/src/views/auth/sign-up/sign-up-card.tsx create mode 100644 packages/vitnode/src/views/auth/sign-up/sign-up-content.tsx create mode 100644 packages/vitnode/src/views/breadcrumb/breadcrumb-main-content.tsx create mode 100644 packages/vitnode/src/views/breadcrumb/breadcrumb-render-content.tsx diff --git a/apps/web/src/components/error-actions.tsx b/apps/web/src/components/error-actions.tsx new file mode 100644 index 000000000..565a7a32a --- /dev/null +++ b/apps/web/src/components/error-actions.tsx @@ -0,0 +1,55 @@ +import { useRouter } from '@tanstack/react-router' +import { Button, buttonVariants } from '@vitnode/core/components/ui/button' +import { cn } from '@vitnode/core/lib/utils' +import { ArrowLeft, HomeIcon } from 'lucide-react' +import { useTranslations } from 'use-intl' + +import { MigrationLink } from '#/components/migration-link' + +/** + * "Go back" and "go home", for a screen that ends 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. Core's error screens take their actions as a slot precisely because this + * is the part that cannot be shared - `router.history.back()` here, + * `next-intl`'s `useRouter().back()` there. + * + * A component rather than a snippet because two screens outside the main shell + * need exactly it: the SSO callback's failure states, and the 404 a + * reset-password page shows on an install with no email adapter. Copied into the + * second of those, the two would have drifted the first time either string + * changed. + * + * `core.global` comes from the root route, which provides it for every page, so + * this renders correctly without a `RouteMessages` above it - which matters, + * because a `notFoundComponent` replaces the component that would have mounted + * one. + * + * Declared at module scope wherever it is used, so it is the same component type + * on every render. + */ +export const ErrorActions = () => { + const router = useRouter() + const t = useTranslations('core.global') + + return ( + <> + + + + + {t('back_home')} + + + ) +} diff --git a/apps/web/src/components/layout/settings-breadcrumb.tsx b/apps/web/src/components/layout/settings-breadcrumb.tsx new file mode 100644 index 000000000..f58d82fa0 --- /dev/null +++ b/apps/web/src/components/layout/settings-breadcrumb.tsx @@ -0,0 +1,78 @@ +import type { SettingsNavKey } from '@vitnode/core/views/auth/settings/settings-nav' + +import { + SETTINGS_ROOT_HREF, + settingsNavHref, +} from '@vitnode/core/views/auth/settings/settings-nav' +import { BreadcrumbMainContent } from '@vitnode/core/views/breadcrumb/breadcrumb-main-content' +import { useTranslations } from 'use-intl' + +import { MigrationLink } from '#/components/migration-link' +import { RouteMessages } from '#/components/route-messages' +import { SETTINGS_NAMESPACES } from '#/lib/settings/panel' + +/** + * The settings breadcrumb - the first crumb in this app that is more than one + * level deep. + * + * /settings Settings + * /settings/overview Settings › Overview + * /settings/security Settings › Security + * + * Which is exactly what the Next.js `@breadcrumb` slot renders for those URLs + * (`routes/breadcrumb/main/settings/**`), through the same core components: the + * trail is `BreadcrumbMainContent`, so the markup, the spacing and the + * "everything but the last crumb is a link" rule are not restated here. + * + * ## A route declares it; this only renders it + * + * Each settings route puts `staticData: { breadcrumb: }` + * next to its own component, and `breadcrumbOf` picks the deepest declaration - + * so `/settings/security` shows the two-crumb trail and `/settings` inherits the + * layout's one-crumb one by declaring nothing. There is no map from pathname to + * label anywhere: the `navKey` a route passes is the one it already uses for its + * own tab title, and the href for it comes from the shared navigation model. + * + * ## Why it mounts its own provider + * + * The crumb is rendered by the *shell* - `_main` passes `` to + * `ThemeLayoutContent` - which is above the settings layout and therefore above + * its `RouteMessages`. So the element a settings route declares renders in a tree + * where only `core.global` is provided, and has to bring `core.auth.settings` + * with it. The set is the same one the settings layout's loader has already + * warmed, so this is a cache read and nothing suspends. + * + * `MigrationLink` rather than the router's `Link`: `/settings` is migrated and + * navigates client-side, and the same component keeps working unchanged for a + * crumb that points at a path the Next.js app still serves. + */ +const SettingsBreadcrumbTrail = ({ navKey }: { navKey?: SettingsNavKey }) => { + const t = useTranslations('core.auth.settings') + const tNav = useTranslations('core.auth.settings.nav') + + /** + * The panel's own path, from the navigation model rather than assembled here, + * so the crumb cannot point somewhere the nav does not. + */ + const href = navKey ? settingsNavHref(navKey) : SETTINGS_ROOT_HREF + + return ( + + ) +} + +export const SettingsBreadcrumb = ({ navKey }: { navKey?: SettingsNavKey }) => ( + + + +) diff --git a/apps/web/src/components/migration-link.tsx b/apps/web/src/components/migration-link.tsx index de5441573..657f27ccf 100644 --- a/apps/web/src/components/migration-link.tsx +++ b/apps/web/src/components/migration-link.tsx @@ -7,14 +7,14 @@ import { isTanStackOwnedPath } from '#/lib/migration-navigation' /** * Linking to a VitNode page while half of VitNode still runs on Next.js. * - * This app owns four routes today - `/`, `/discover`, the `/api/*` mount and the - * `@vitnode/example` plugin's `/example` - and search results point at all of the - * ones it does not: `/blog/post-30`, - * `/files/...`, `/admin/...`, whatever a plugin indexed. Handing every - * internal-looking path to `` routes those into *this* router, which has - * nothing to match them with, so a perfectly good blog post becomes a TanStack - * not-found page. During a strangler migration a full document load to the - * running Next.js app is the correct answer, not a fallback. + * The set this app owns grows every stage - the front page, `/discover`, + * `/search`, the auth screens, `/files`, `/settings/*`, the `/api/*` mount and + * whatever a plugin declares - and links point at plenty of routes it still does + * not: `/blog/post-30`, `/admin/...`, `/users/`, whatever a plugin indexed. + * Handing every internal-looking path to `` routes those into *this* + * router, which has nothing to match them with, so a perfectly good blog post + * becomes a TanStack not-found page. During a strangler migration a full document + * load to the running Next.js app is the correct answer, not a fallback. * * So: ask the router what it owns, and let it answer. * @@ -26,7 +26,11 @@ import { isTanStackOwnedPath } from '#/lib/migration-navigation' * `/blog` is migrated it appears in the route tree, `isTanStackOwnedPath` starts * answering `true` for it, and nothing here changes. Stage 5 is the proof: a * plugin declared `/example`, `lib/plugin-routes.ts` mounted it on the same tree, - * and this file was not touched. + * and this file was not touched. Stage 9 is the second proof and the louder one: + * `/register` and `/settings` are in the *header's* own link record + * (`user-header-model.ts`), they were document loads into the Next.js app the day + * before, and migrating them added route files and edited neither this component + * nor that record. `src/tests/header-navigation.test.ts` pins it. * * The rule itself lives in `#/lib/migration-navigation`, because a link is not * the only thing that has to make it: an auth flow finishing a sign-in navigates diff --git a/apps/web/src/components/realtime-listeners.tsx b/apps/web/src/components/realtime-listeners.tsx index 3a7806321..1bdbc7cac 100644 --- a/apps/web/src/components/realtime-listeners.tsx +++ b/apps/web/src/components/realtime-listeners.tsx @@ -85,6 +85,21 @@ import { socketUserIdFromSession } from '#/lib/realtime' * from it, and the identity change is what re-opens the socket so the server * re-reads the cookie on a fresh handshake. Which is what stops the previous * visitor's notifications from reaching this browser, with no page reload. + * + * ## No route guard depends on this being mounted + * + * Worth stating because for a while one silently did. The observer below is an + * *active* one, and `invalidateQueries` refetches active queries - so while a + * guard read the session through `ensureQueryData`, which ignores invalidation + * entirely, the refetch performed for **this** component was the only thing + * making a post-sign-in navigation see the new visitor. Moving this into the + * shell's `listeners` slot, or gating it, would have turned every sign-in into a + * bounce back to the login page, in a file neither change mentions. + * + * `ensureAuthState` reads through `fetchQuery` now, which consults the mark + * itself, so the guards are correct with nothing observing the entry at all. + * What this component is still responsible for is its own job: the socket's + * identity. Move it freely on those grounds. */ export const RealtimeListeners = () => { const { data: session } = useQuery(sessionQueryOptions()) diff --git a/apps/web/src/lib/auth/actions.ts b/apps/web/src/lib/auth/actions.ts index 408214b56..e0259da0d 100644 --- a/apps/web/src/lib/auth/actions.ts +++ b/apps/web/src/lib/auth/actions.ts @@ -1,4 +1,7 @@ +import type { ChangePasswordSubmit } from '@vitnode/core/views/auth/password-reset/change-password-form/change-password-form-content' +import type { PasswordResetSubmit } from '@vitnode/core/views/auth/password-reset/form/password-reset-form-content' import type { SignInSubmit } from '@vitnode/core/views/auth/sign-in/form/sign-in-form-content' +import type { SignUpSubmit } from '@vitnode/core/views/auth/sign-up/form/sign-up-form-content' import type { SSOSelectProvider } from '@vitnode/core/views/auth/sso/buttons/sso-buttons-content' import type { SSOCallbackResult } from '@vitnode/core/views/auth/sso/callback/sso-callback-result' @@ -7,7 +10,16 @@ import { useRouter } from '@tanstack/react-router' import type { SsoCallbackInput } from '#/lib/auth/contract' -import { completeSso, signIn, signOut, startSso } from '#/lib/auth/mutations' +import { shouldRefreshSessionAfterSignUp } from '#/lib/auth/contract' +import { + changePasswordFromReset, + completeSso, + requestPasswordReset, + signIn, + signOut, + signUp, + startSso, +} from '#/lib/auth/mutations' import { invalidateSession, sessionQueryOptions, @@ -15,7 +27,10 @@ import { } from '#/lib/auth/query' import { anonymousSession, + changePasswordFormResult, + passwordResetFormResult, signInFormResult, + signUpFormResult, ssoCallbackResult, ssoStartFeedback, } from '#/lib/auth/screens' @@ -56,6 +71,13 @@ import { useMigrationNavigate } from '#/lib/migration-navigation' * destination's guard performs through the one query definition. Doing it before * navigating is what makes that read see the new cookie. * + * The guard notices because `ensureAuthState` reads through `fetchQuery`, and an + * invalidated entry is stale to `isStaleByTime`. It is worth being exact about + * that rather than trusting "invalidate then read": `ensureQueryData` - the + * obvious call, and the one this used - returns cached data without consulting + * the mark at all, so the guard would have decided on the anonymous session and + * bounced a visitor who had just signed in. See the note on `ensureAuthState`. + * * The navigation goes through `useMigrationNavigate` rather than * `router.navigate`, because `?returnTo=` names somewhere the visitor was * heading and most of VitNode has not moved yet. `/discover` is a client-side @@ -141,9 +163,14 @@ export const useCompleteSsoAction = (params: null | SsoCallbackInput) => { * visitor sitting on a page behind `_authenticated` is redirected out of it by * the guard that owns that rule, rather than by anything here. * - * Exported for the shell migration that will mount the header. Nothing in this - * app renders a sign-out control yet, and adding one would mean migrating the - * header, which is a different stage. + * The header's user menu is what calls it (`#/components/layout/user-header`), + * and it is the only sign-out control this app renders - which is what let Stage + * 9 delete the `/account` scaffold that existed to exercise this before there + * was a header. + * + * The failure is *reported* rather than thrown, and the caller decides: the + * header raises the internal-error toast and stays signed in, which is honest - + * a session that could not be ended is still a session. */ export const useSignOutAction = () => { const queryClient = useQueryClient() @@ -163,3 +190,92 @@ export const useSignOutAction = () => { return result } } + +/** + * Registering, in the shape `SignUpFormContent` submits. + * + * The one action here with two success paths, and the ordering in the verified + * one is the whole reason it is a hook: + * + * signUp() the API mints the session, saveApiCookies puts + * the cookie on this response + * invalidateSession() the canonical entry every guard reads is marked + * stale, and every component observing it + * refetches before this resolves + * navigate(destination()) and arrives as the new visitor + * + * Navigating first would arrive at a guard still holding the anonymous session + * and bounce a freshly-registered visitor to the login page. The guard sees the + * mark because `ensureAuthState` reads through `fetchQuery` rather than + * `ensureQueryData`, which would have ignored it - see the note there. + * + * `shouldRefreshSessionAfterSignUp` decides which path this is, rather than an + * inline `result.emailVerified`: an unverified account is *not* a session, so the + * cache must not be touched and the form must not be told to stand down. It gets + * `{ emailConfirmation }` instead and swaps itself for the "check your email" + * screen. + * + * There is no second auth store. This writes to the one entry `#/lib/auth/query` + * owns, exactly as the sign-in action does. + * + * `destination` is a thunk for the same reason as on the login page: where to + * land can depend on a search parameter that changes under the form, and reading + * it at submit time rather than at mount time is what keeps the two in step. It + * goes through `useMigrationNavigate`, so a destination this app does not own yet + * becomes a document load into the Next.js app rather than a client navigation to + * a route that cannot render. + */ +export const useSignUpAction = (destination: () => string): SignUpSubmit => { + const queryClient = useQueryClient() + const navigate = useMigrationNavigate() + + return async (values) => { + const result = await signUp({ data: values }) + + if (shouldRefreshSessionAfterSignUp(result)) { + await invalidateSession(queryClient) + await navigate(destination()) + } + + return signUpFormResult(result) + } +} + +/** + * Asking for a password-reset link, in the shape `PasswordResetFormContent` + * submits. + * + * A plain function rather than a hook: nothing about it touches the session, the + * router or any cached state. It cannot - the API mints no session here, and the + * visitor stays exactly where they are while the form swaps itself for the + * confirmation screen. + * + * The result says only whether the request was accepted. An address with an + * account and one without produce the identical `{ ok: true }`, because the API + * answers the identical `201`, and nothing in this path may add a distinction it + * withholds. + */ +export const requestPasswordResetAction: PasswordResetSubmit = async (values) => + passwordResetFormResult(await requestPasswordReset({ data: values })) + +/** + * Setting a new password from a recovery link, in the shape + * `ChangePasswordFormContent` submits. + * + * Also a plain function, and deliberately so: the API changes the password and + * deletes the recovery row without minting a session, so there is nothing to + * refresh and nobody to sign in. Inventing either here would be this app + * asserting an authentication the server never performed. + * + * The link travels as an already-parsed `RecoveryLink` - the route reads it + * out of the URL through `parseRecoveryLink` - so `userId` is a number by the time + * it reaches here, and the server function validates it again on arrival because + * its input is whatever a caller posts. + * + * Where the visitor goes afterwards is `onChanged` on the shared form, not this: + * the destination is the login page, and moving the router belongs to the route + * that has one. + */ +export const changePasswordFromResetAction: ChangePasswordSubmit = async ( + values, +) => changePasswordFormResult(await changePasswordFromReset({ data: values })) diff --git a/apps/web/src/lib/auth/contract.ts b/apps/web/src/lib/auth/contract.ts index 34eed69a6..e0073340c 100644 --- a/apps/web/src/lib/auth/contract.ts +++ b/apps/web/src/lib/auth/contract.ts @@ -1,3 +1,5 @@ +import { RATE_LIMIT_STATUS } from '@vitnode/core/lib/fetcher/rate-limit' +import { signUpConflictReason } from '@vitnode/core/views/auth/sign-up/form/schema' import { z } from 'zod' /** @@ -291,3 +293,289 @@ export const parseSsoCallback = ({ return { ok: true, params: params.data } } + +/** + * ## Registration and password recovery + * + * Three more mutations, and the same shape as the four above: a schema for what + * a browser may send, and a total function from an HTTP status to a finite + * result. What is new is that two of them carry a captcha token and one of them + * can mint a session, so the notes below are about those two facts. + */ + +/** + * A solved captcha token, as it arrives from the widget. + * + * `""` is accepted and meaningful: `useCaptcha` reports itself ready with no + * token when this deployment has no captcha configured, and the API's + * `captchaMiddleware` is a no-op in exactly that case. So an empty string is + * "there was nothing to solve", and it is the transport's job to send no header + * rather than an empty one. + * + * The cap is generous because the tokens are: a Turnstile response is around + * 2 KB and a reCAPTCHA v3 one is longer still. It exists so a crafted call + * cannot make this server forward an unbounded header. + */ +const captchaTokenSchema = z.string().max(8192).default('') + +/** + * What registration accepts. + * + * The API's own rules, restated rather than imported, for the reason the sign-in + * schema gives: `users/routes/sign-up.route.ts` pulls in `UserModel`, + * `PasswordModel` and the Hono runtime with them, and this module is reachable + * from the browser bundle. Every bound here is the API's: + * + * email z.email().toLowerCase() identical + * name min 3, no doubled spaces, and the character class from + * `nameRegex` - `[\p{L}\p{N}._@ -]` + * password min 8 (the *form* asks for more; see + * `createPasswordZodSchema` in @vitnode/core) + * newsletter optional boolean + * + * The maxima are this layer's own and have no counterpart on the API, which + * bounds none of these: 32 characters of name because that is what the + * registration form allows, and 1024 of password so a submission cannot ask this + * server to hash an unbounded string. + */ +export const signUpInputSchema = z.object({ + captchaToken: captchaTokenSchema, + email: z.email().toLowerCase(), + name: z + .string() + .min(3) + .max(32) + .regex(/^(?!.* {2})[\p{L}\p{N}._@ -]*$/u), + newsletter: z.boolean().optional(), + password: z.string().min(8).max(1024), +}) + +export type SignUpInput = z.infer + +/** + * The `201` body, validated rather than trusted. + * + * Two reasons it is a schema and not a cast. It decides whether the visitor is + * now signed in - `emailVerified` is what the API branched on when it chose to + * mint a session - so a missing or wrongly-typed field must not read as `false` + * by accident. And the sign-up route declares its `400` and `409` without a + * `content` block, which makes the fetcher's inferred `json()` type `unknown`; + * parsing is how that becomes a shape rather than an assertion. + */ +const signUpSuccessSchema = z.object({ + email: z.string(), + emailVerified: z.boolean(), +}) + +/** + * What a reset request accepts. One address and the captcha the route requires. + */ +export const passwordResetRequestInputSchema = z.object({ + captchaToken: captchaTokenSchema, + email: z.email().toLowerCase(), +}) + +export type PasswordResetRequestInput = z.infer< + typeof passwordResetRequestInputSchema +> + +/** + * What a password change accepts. + * + * The link's two values *and* a password, validated here even though + * `parseRecoveryLink` in `@vitnode/core` already judged the first two on the way + * out of the URL. That is not redundancy: a server function is a public + * same-origin endpoint, so its input is whatever a caller posts, and the + * component that parsed the URL is not in the call path. The bounds match that + * parser's - a base64url token, a safe-integer id - so a link this app was + * willing to render a form for is a link this schema accepts. + */ +export const changePasswordInputSchema = z.object({ + password: z.string().min(8).max(1024), + token: z + .string() + .min(16) + .max(512) + .regex(/^[A-Za-z0-9_-]+$/), + userId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), +}) + +export type ChangePasswordInput = z.infer + +/** + * ## The three results + * + * signUp { ok: true, email, emailVerified } + * { reason: 'email_exists' | 'name_exists' } -> mark a field + * { reason: 'conflict' } -> a 409 we + * could not + * classify + * { reason: 'invalid' } -> the API + * refused the + * body or the + * captcha + * { reason: 'rate_limited' | 'server_error' } + * + * requestPasswordReset { ok: true } + * { reason: 'invalid' | 'rate_limited' | 'server_error' } + * + * changePasswordFromReset { ok: true } + * { reason: 'invalid_token' } -> ask for a + * fresh link + * { reason: 'rate_limited' | 'server_error' } + * + * `rate_limited` is kept apart from `server_error` even though today's screens + * render both the same way. The API answers `429` with a `Retry-After` header, + * and `notifyRateLimited` - the toast the browser fetcher raises - is a no-op on + * a server, so a mutation that goes through a server function is the *only* place + * that fact can be observed. Collapsing it here would make it unobservable + * anywhere. + * + * `email` and `emailVerified` come back from sign-up because the caller needs + * both: the address is printed on the "check your email" screen, and the flag is + * the difference between a visitor who now holds a session cookie and one who + * does not. See {@link shouldRefreshSessionAfterSignUp}. + */ +export type SignUpResult = + | { email: string; emailVerified: boolean; ok: true } + | { + ok: false + reason: + | 'conflict' + | 'email_exists' + | 'invalid' + | 'name_exists' + | 'rate_limited' + | 'server_error' + } + +export type PasswordResetRequestResult = + | { ok: false; reason: 'invalid' | 'rate_limited' | 'server_error' } + | { ok: true } + +export type ChangePasswordResult = + | { ok: false; reason: 'invalid_token' | 'rate_limited' | 'server_error' } + | { ok: true } + +/** + * A registration attempt, from the status and whatever body came with it. + * + * `201` is the route's only success, and it is the one status whose body is + * read - through {@link signUpSuccessSchema}, so a `201` the API answered with + * something unexpected is a `server_error` rather than a visitor who is + * mysteriously signed in or not. + * + * `400` is `invalid`, and it covers two things the API spells the same way: a + * body its schema refused, and a captcha `captchaMiddleware` refused + * (`"Captcha token is required"`, `"Captcha validation failed"` - both `400`). + * Neither message travels; a caller that wants to distinguish them would need + * the API to say so, which today it does not. + * + * `409` goes through core's `signUpConflictReason`, the single classifier both + * frontends use, so `"Email already exists"` becomes `email_exists` here and in + * the Next.js server action from the same code. A `409` whose body matches + * neither known message is `conflict`: still a conflict, just not one that can be + * pinned to a field. + */ +export const signUpResultFromStatus = ( + status: number, + { body, conflict }: { body?: unknown; conflict?: string } = {}, +): SignUpResult => { + if (status === 201) { + const parsed = signUpSuccessSchema.safeParse(body) + + if (!parsed.success) return { ok: false, reason: 'server_error' } + + return { + email: parsed.data.email, + emailVerified: parsed.data.emailVerified, + ok: true, + } + } + + if (status === 400) return { ok: false, reason: 'invalid' } + if (status === 409) { + const reason = signUpConflictReason(conflict ?? '') + + return { + ok: false, + reason: reason === 'unknown' ? 'conflict' : reason, + } + } + if (status === RATE_LIMIT_STATUS) return { ok: false, reason: 'rate_limited' } + + return { ok: false, reason: 'server_error' } +} + +/** + * A reset request's outcome. + * + * `201` is the route's only declared response, and the API answers it whether or + * not the address belongs to an account and whether or not it decided to skip + * the send because one was requested in the last five minutes. That is the + * product's anti-enumeration behaviour and this function preserves it exactly: + * there is no reason in {@link PasswordResetRequestResult} that could mean "no + * such account", so no caller can accidentally reveal one. + * + * `400` is `invalid` - a malformed address, or a captcha the middleware refused. + * It says nothing about whether the address exists. + */ +export const passwordResetRequestResultFromStatus = ( + status: number, +): PasswordResetRequestResult => { + if (status === 201) return { ok: true } + if (status === 400) return { ok: false, reason: 'invalid' } + if (status === RATE_LIMIT_STATUS) return { ok: false, reason: 'rate_limited' } + + return { ok: false, reason: 'server_error' } +} + +/** + * A password change's outcome. + * + * `400` is the one a visitor can act on. The API looks the recovery row up by + * `userId` *and* `token` *and* an unexpired `expiresAt`, and answers + * `400 "Invalid token"` when any of the three does not match - 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 of them. + * + * Nothing here signs anybody in, because the route mints no session: it hashes + * the new password, writes it, and deletes the recovery row. A caller must not + * invent a session refresh around it. + */ +export const changePasswordResultFromStatus = ( + status: number, +): ChangePasswordResult => { + if (status === 201) return { ok: true } + if (status === 400) return { ok: false, reason: 'invalid_token' } + if (status === RATE_LIMIT_STATUS) return { ok: false, reason: 'rate_limited' } + + return { ok: false, reason: 'server_error' } +} + +/** + * Whether registration produced a session the canonical session query has to go + * and read. + * + * The one decision that connects sign-up to the rest of the app, written as a + * function so it is stated once and tested without a browser. + * + * `true` only for a successful sign-up with `emailVerified`. That is precisely + * when the API called `createSessionByUserId` on the same request, which means + * the `201` carried a `Set-Cookie`, which means `saveApiCookies` put it on the + * response the browser is reading - so the *next* read of `/users/session` + * answers with the new visitor and the cached one is stale. + * + * `false` for an unverified account, and that matters more than it looks: + * inventing a refresh there would replace a known-anonymous session with another + * known-anonymous session and, worse, invite a caller to navigate as though the + * visitor were signed in. They are not - the account is waiting on a + * confirmation link the API does not send yet (`// TODO: Send verification + * email`), and the screen for that is the confirmation view. + * + * There is deliberately no equivalent for the two recovery mutations: neither + * mints a session, so neither has a session to refresh. + */ +export const shouldRefreshSessionAfterSignUp = ( + result: SignUpResult, +): boolean => result.ok && result.emailVerified diff --git a/apps/web/src/lib/auth/mutations.ts b/apps/web/src/lib/auth/mutations.ts index f14bc6bca..8a8dc2389 100644 --- a/apps/web/src/lib/auth/mutations.ts +++ b/apps/web/src/lib/auth/mutations.ts @@ -1,15 +1,21 @@ import { createServerFn } from '@tanstack/react-start' import { + changePasswordInputSchema, + passwordResetRequestInputSchema, signInInputSchema, signOutInputSchema, + signUpInputSchema, ssoCallbackInputSchema, ssoStartInputSchema, } from '#/lib/auth/contract' import { + changePasswordFromResetOnApi, completeSsoOnApi, + requestPasswordResetOnApi, signInOnApi, signOutOnApi, + signUpOnApi, startSsoOnApi, } from '#/server/auth.server' @@ -96,3 +102,51 @@ export const startSso = createServerFn({ method: 'POST' }) export const completeSso = createServerFn({ method: 'POST' }) .validator(ssoCallbackInputSchema) .handler(async ({ data }) => await completeSsoOnApi(data)) + +/** + * Registers a new account. + * + * A server function for three reasons, all of which a browser fetch would fail + * at: the reply may carry a session `Set-Cookie` that has to be copied onto the + * response the browser is reading, the API wants the visitor's `user-agent` and + * forwarded IP for the device record and the rate limiter, and the captcha token + * travels as a header the API reads rather than as part of the body. + * + * `{ ok: true, emailVerified: true }` means the session cookie is on the response + * this call is answering with - the caller must refresh the canonical session + * before it navigates, and {@link shouldRefreshSessionAfterSignUp} is the rule. + * `{ ok: true, emailVerified: false }` means the account exists and the visitor is + * still anonymous. + */ +export const signUp = createServerFn({ method: 'POST' }) + .validator(signUpInputSchema) + .handler(async ({ data }) => await signUpOnApi(data)) + +/** + * Asks for a password-reset link to be emailed. + * + * A server function for the captcha header and the forwarded request state, not + * for a cookie - this one mints nothing. The result says only whether the request + * was accepted, which is all the API says: it answers the same `201` for an + * address with an account and one without, and nothing here may add a distinction + * the API deliberately withholds. + */ +export const requestPasswordReset = createServerFn({ method: 'POST' }) + .validator(passwordResetRequestInputSchema) + .handler(async ({ data }) => await requestPasswordResetOnApi(data)) + +/** + * Sets a new password from a recovery link. + * + * The `token` and `userId` are validated here rather than taken on trust, and + * that is the point of the validator: they reach this endpoint as whatever a + * caller posted, not as whatever the recovery URL contained. `parseRecoveryLink` + * in `@vitnode/core` judges the URL on the way *into* the screen; this judges the + * call on the way *out* of the browser, and the two are different boundaries. + * + * Deliberately does not sign anybody in, because the API does not: it changes the + * password and deletes the recovery row. The visitor goes to the login page. + */ +export const changePasswordFromReset = createServerFn({ method: 'POST' }) + .validator(changePasswordInputSchema) + .handler(async ({ data }) => await changePasswordFromResetOnApi(data)) diff --git a/apps/web/src/lib/auth/password-reset-route.ts b/apps/web/src/lib/auth/password-reset-route.ts new file mode 100644 index 000000000..3bc7834db --- /dev/null +++ b/apps/web/src/lib/auth/password-reset-route.ts @@ -0,0 +1,166 @@ +import type { RecoveryLink } from '@vitnode/core/views/auth/password-reset/recovery-link' + +import { parseRecoveryLink } from '@vitnode/core/views/auth/password-reset/recovery-link' + +/** + * What `/login/reset-password` reads out of its URL, and what it turns that + * into. + * + * Pure functions, no transport and no React, so the route's whole contract can + * be stated and tested without a router - the same split `lib/search/search-request.ts` + * makes for `/search`. `src/tests/auth-routes.test.ts` is the test. + * + * One route serves two screens, and this module is where that is decided: + * + * /login/reset-password -> ask for a link + * /login/reset-password?token=..&userId=.. -> choose a new password + * + * which is exactly what the Next.js `PasswordResetView` does with + * `if (token && userId)`, only spelled as a rule a crafted URL cannot walk past. + */ + +/** + * The two search parameters, in the shape the *router* produces them. + * + * `userId` is `number | string` rather than the `string` the URL literally + * contains, and that is not laxness - it is the only spelling that survives a + * round trip. TanStack's default search parsing is `JSON.parse` per value, so + * `?userId=123` reaches `validateSearch` as the **number** `123`; the default + * stringifier is its inverse, and re-serialising the *string* `'123'` produces + * `?userId=%22123%22`. The server compares the location it rebuilds against the + * one that arrived (`loadServerRoute`) and redirects when they differ, so + * coercing here would turn every recovery link into a 307 to a quoted URL. + * + * `token` has no such problem: the stringifier returns a string unchanged unless + * it parses as JSON, and a base64url token does not. + */ +export interface PasswordResetSearch { + token?: string + userId?: number | string +} + +/** + * The route's search schema - a normaliser rather than a validator, for the same + * reason `/search`'s is. + * + * This URL is typed by strangers and pasted out of emails, so every malformed + * spelling has to render *a* page rather than an error boundary: the answer to + * `?userId=true`, `?token=`, or a missing half is the request form, which is the + * page a visitor who needs a new link wants anyway. + * + * Two rules, and both matter: + * + * - **Drop what cannot be a value.** An unusable parameter is returned as an + * *absent* key, so the router has nothing to write back and the URL settles to + * the clean one - `/search`'s trick, and what stops junk from riding along + * through the reset flow. + * - **Never coerce what can.** A kept value is the value that arrived, byte for + * byte, so `stringify(parse(url)) === url` and the canonical-location check + * does not redirect. See {@link PasswordResetSearch}. + * + * Judging whether the pair is *usable* is deliberately not done here - that is + * {@link passwordResetMode}, through core's `parseRecoveryLink`, so the rule + * lives once and both frameworks apply it. + */ +export const normalizePasswordResetSearch = ( + input: Record, +): PasswordResetSearch => { + const { token, userId } = input + + return { + ...(typeof token === 'string' && token !== '' ? { token } : {}), + ...(typeof userId === 'number' || + (typeof userId === 'string' && userId !== '') + ? { userId } + : {}), + } +} + +/** + * Which of the two screens this URL asks for. + * + * A union rather than a boolean, so the change-password branch carries the + * *parsed* link and there is no way to reach that screen without one. Which is + * the whole of "do not pass partially present credentials to the API": + * `parseRecoveryLink` answers `null` unless both values are present and both are + * well formed, and this type has no shape in which half a link could travel. + * + * Not an authorization decision. The API looks the recovery row up by `userId` + * *and* `token` *and* an unexpired `expiresAt` and answers `400` when any of the + * three does not match; this only decides which form is worth rendering. + */ +export type PasswordResetMode = + { link: RecoveryLink; mode: 'change' } | { mode: 'request' } + +export const passwordResetMode = ( + search: PasswordResetSearch, +): PasswordResetMode => { + const link = parseRecoveryLink(search) + + return link ? { link, mode: 'change' } : { mode: 'request' } +} + +/** + * The strings the request form renders. + * + * `core.global` because the root's provider is replaced by the route's, and the + * error toasts read `core.global.errors.*` from it. `core.auth.sign_up` because + * both recovery screens borrow the email and password field labels from the + * registration form - which is what the Next.js view's two `I18nProvider`s + * already declare. + * + * `core.auth.reset_password` is in the *base* set rather than the request-only + * one because the page's title comes from it in **both** modes, exactly as the + * Next.js route's `generateMetadata` does. Warming it in change mode is one + * seven-string namespace, and the alternative is a differently-titled tab + * depending on which half of the flow a visitor is in. + */ +const PASSWORD_RESET_BASE_NAMESPACES = [ + 'core.global', + 'core.auth.sign_up', + 'core.auth.reset_password', +] as const + +/** The base set, plus the change-password screen's own copy. */ +const CHANGE_PASSWORD_NAMESPACES = [ + ...PASSWORD_RESET_BASE_NAMESPACES, + 'core.auth.change_password', +] as const + +/** + * What to warm, and what to mount - one function, so the loader and the provider + * cannot ask for different sets. + * + * They must not: `RouteMessages` reads the entry back with `useSuspenseQuery` + * over the same `intlQueryOptions`, and the namespace list is part of the query + * key. A provider asking for a set nobody warmed suspends the whole response. + */ +export const passwordResetNamespaces = ( + mode: PasswordResetMode['mode'], +): readonly string[] => + mode === 'change' + ? CHANGE_PASSWORD_NAMESPACES + : PASSWORD_RESET_BASE_NAMESPACES + +/** + * Whether this deployment has password recovery at all. + * + * The API sends the reset link through the configured email adapter, so with no + * adapter there is no flow - and the Next.js view answers `notFound()` rather + * than rendering a form whose submit could never arrive. Preserved here as a + * named predicate over the deployment configuration, so the route reads as the + * rule rather than as a negated flag. + * + * Note what `isEmail: false` covers: it is also what + * `ANONYMOUS_MIDDLEWARE_CONFIG` says when the configuration could not be read at + * all. On this route that means an API outage renders the 404 rather than an + * error screen - a degradation, but the safe direction, and one that follows + * from Stage 6's decision that a failed configuration read must not blank the + * auth pages. Changing it would mean teaching that read to distinguish "no + * adapter" from "we could not ask". + */ +export const hasPasswordRecovery = ({ + isEmail, +}: { + isEmail: boolean +}): boolean => isEmail diff --git a/apps/web/src/lib/auth/query.ts b/apps/web/src/lib/auth/query.ts index 2f26b2731..bbd5c4d5f 100644 --- a/apps/web/src/lib/auth/query.ts +++ b/apps/web/src/lib/auth/query.ts @@ -41,7 +41,11 @@ import { authStateFromSession, SESSION_QUERY_KEY } from './shared' * page whose data the API then refuses; it cannot cost private data, because the * API is the boundary and it re-reads the cookie every time. * - * Sign-in and sign-out do not wait this out - they replace the value outright. + * Sign-in and sign-out do not wait this out. Sign-out replaces the value + * outright ({@link setSessionData}); the rest mark the entry invalidated, which + * {@link ensureAuthState}'s read treats as stale whatever the clock says. So + * this window governs only the passage of time, never a mutation this app + * performed. */ const SESSION_STALE_TIME = 30_000 @@ -88,29 +92,66 @@ export const sessionQueryOptions = () => }) /** - * The auth state, fetching the session first if this client has not read it yet. + * The auth state, reading the session first if what is cached cannot be trusted. * * What a route's `beforeLoad` calls, and the only function it needs. It is safe - * to call on a preload: `ensureQueryData` is a read that fills a cache entry - - * it cannot create or end a session, and the API call behind it is a `GET` whose + * to call on a preload: this is a read that fills a cache entry - it cannot + * create or end a session, and the API call behind it is a `GET` whose * `Set-Cookie` this app deliberately does not save (`saveApiCookies` is for * responses to sign-in, not to a session read). Two routes guarding themselves - * during one navigation share the single in-flight request. + * during one navigation share the single in-flight request, because + * `query.fetch()` returns the promise already in flight rather than starting a + * second one. * * Returns the decision material and leaves the decision to the caller. No * `redirect()` here on purpose: where a blocked visitor is sent is a property of * the route that blocked them, so it belongs in the route tree - which is also * the only layer that should be importing the router. * + * ## Why `fetchQuery` and not `ensureQueryData` + * + * They differ in exactly one case, and it is the case a guard exists for. + * `ensureQueryData` returns whatever is cached the moment anything is cached: + * + * if (cachedData !== undefined) return Promise.resolve(cachedData) + * + * - no staleness check, and **no check of whether the entry was invalidated**. + * So a guard reading through it could not see a sign-in that had just happened. + * {@link invalidateSession} marks the entry, `ensureQueryData` ignores the mark, + * and the guard decides on the previous visitor. + * + * That was survivable only by accident. `invalidateQueries` ends in + * `refetchQueries({ type: 'active' })`, so the refetch that actually kept this + * correct was the one performed for the session observer `RealtimeListeners` + * mounts at the root - a component mounted for the WebSocket's sake, whose + * removal or relocation into the shell would have silently turned every + * post-sign-in navigation into a bounce back to the login page. A guard must not + * depend on an unrelated component being mounted. + * + * `fetchQuery` asks the query itself, through `isStaleByTime`: + * + * invalidated -> read again (a sign-in just happened) + * older than SESSION_STALE_TIME -> read again (the window has passed) + * otherwise -> the cached value, no round trip + * + * which is precisely what {@link SESSION_STALE_TIME} is documented to buy, and + * the preload behaviour that motivates it is unchanged: hovering a guarded link + * inside the window still costs nothing. + * * ## It resolves only when the session is actually known * * An {@link AuthState} is returned when - and only when - the session query * succeeded. `getSession` rejects if the session could not be read at all (a - * 429, a 500, an unreachable API), so `ensureQueryData` rejects and so does - * this. That is deliberate and it is the whole point of the contract: + * 429, a 500, an unreachable API), so `fetchQuery` rejects and so does this. + * That is deliberate and it is the whole point of the contract: * `authStateFromSession` describes two *known* states, and there is no third * value for "we could not find out". * + * `fetchQuery` rather than `prefetchQuery` matters here too - the latter is the + * same read with `.catch(noop)` on the end, which would turn an outage into a + * silently stale answer. See {@link prefetchSession}, which wants exactly that + * and is the only caller allowed to. + * * A caller must therefore not treat a rejection as "signed out". Only * `auth.isAuthenticated === false` means that. A rejection propagating out of a * `beforeLoad` is an ordinary route error and takes the router's normal error @@ -120,7 +161,7 @@ export const sessionQueryOptions = () => export const ensureAuthState = async ( queryClient: QueryClient, ): Promise => - authStateFromSession(await queryClient.ensureQueryData(sessionQueryOptions())) + authStateFromSession(await queryClient.fetchQuery(sessionQueryOptions())) /** * Replace the cached session with one the server just answered with. @@ -140,13 +181,32 @@ export const setSessionData = ( } /** - * Mark the cached session stale and let the next reader fetch the truth. + * Mark the cached session stale, so the next reader fetches the truth. * * The other half of the pair above, for the cases where the client cannot know - * the new session: an SSO callback, a profile change, a sign-out whose response - * only says it worked. Invalidating rather than clearing keeps the current - * answer on screen while the fresh one is fetched, instead of blanking every - * component that reads the session. + * the new session: a sign-in, a verified sign-up, an SSO callback, a sign-out + * whose response only says it worked. Invalidating rather than clearing keeps + * the current answer on screen while the fresh one is fetched, instead of + * blanking every component that reads the session. + * + * ## What "the next reader" means, exactly + * + * Two different things, and both have to hold or a sign-in navigates as the + * previous visitor: + * + * - **A guard.** {@link ensureAuthState} goes through `fetchQuery`, which asks + * `isStaleByTime` - and an invalidated entry is stale by definition. So the + * mark is what the guard acts on, with no observer involved. That is the half + * this used to get wrong; see the long note there. + * - **A component.** `invalidateQueries` also ends in + * `refetchQueries({ type: 'active' })`, so anything currently observing the + * entry - the header, the WebSocket identity sync - refetches. Awaiting this + * call is therefore how a caller knows the header has caught up too, which is + * why every auth action awaits it before it navigates. + * + * The first is a correctness property and the second is a rendering one. They + * are easy to conflate because until Stage 9 only the second was actually + * running. */ export const invalidateSession = async ( queryClient: QueryClient, diff --git a/apps/web/src/lib/auth/screens.ts b/apps/web/src/lib/auth/screens.ts index 0dd596d95..96e0daaa4 100644 --- a/apps/web/src/lib/auth/screens.ts +++ b/apps/web/src/lib/auth/screens.ts @@ -1,10 +1,16 @@ +import type { ChangePasswordMutationResult } from '@vitnode/core/views/auth/password-reset/change-password-form/schema' +import type { PasswordResetMutationResult } from '@vitnode/core/views/auth/password-reset/form/schema' import type { SignInMutationResult } from '@vitnode/core/views/auth/sign-in/form/schema' +import type { SignUpMutationResult } from '@vitnode/core/views/auth/sign-up/form/schema' import type { SSOStartResult as SsoButtonFeedback } from '@vitnode/core/views/auth/sso/buttons/sso-buttons-content' import type { SSOCallbackResult } from '@vitnode/core/views/auth/sso/callback/sso-callback-result' import type { + ChangePasswordResult, CompleteSsoResult, + PasswordResetRequestResult, SignInResult, + SignUpResult, SsoStartResult, } from '#/lib/auth/contract' import type { SessionApi } from '#/lib/session' @@ -99,3 +105,84 @@ export const anonymousSession = (session: SessionApi): SessionApi => ({ ...session, user: null, }) + +/** + * A registration attempt, as `SignUpFormContent` reads it. + * + * The two vocabularies line up almost exactly, and where they do not it is + * because the contract knows more than the screen can use: + * + * ok, emailVerified: true undefined the caller is navigating + * ok, emailVerified: false { emailConfirmation } "check your email" + * email_exists { message } marks the email field + * name_exists { message } marks the username field + * conflict, invalid, { message: 'Internal the internal-error toast + * rate_limited, server_error Server Error' } + * + * The last row is where information is deliberately lost. `conflict` is a `409` + * whose field could not be identified, `invalid` is the API refusing the body or + * the captcha, and `rate_limited` is the limiter - and the registration form has + * one thing to say about all three, because a visitor cannot act on any of them + * differently. The distinctions survive where they are useful, in the server log + * `#/server/auth.server` writes. + * + * **`undefined` is only correct once the caller has actually navigated.** The + * shared form treats "nothing to report" as "we are leaving", so a caller that + * maps a verified sign-up to `undefined` and then does not move the router leaves + * a form that appears to have done nothing. `useSignUpAction` refreshes the + * session and navigates before it returns, which is what makes this row true. + */ +export const signUpFormResult = ( + result: SignUpResult, +): SignUpMutationResult => { + if (result.ok) { + return result.emailVerified + ? undefined + : { emailConfirmation: result.email } + } + + if (result.reason === 'email_exists') return { message: 'email_exists' } + if (result.reason === 'name_exists') return { message: 'name_exists' } + + return { message: 'Internal Server Error' } +} + +/** + * A reset request, as `PasswordResetFormContent` reads it. + * + * `undefined` is "accepted", and it is what an existing address and a + * non-existent one both produce - the API answers the same `201` for either, and + * this mapping has no shape in which the difference could be expressed. That is + * the anti-enumeration property, preserved by having nothing to preserve it + * from. + * + * Every failure is the one toast: `invalid` (a captcha the API refused), + * `rate_limited` and `server_error` all mean "we did not manage to send it", and + * the form stays where it is so the visitor can try again. + */ +export const passwordResetFormResult = ( + result: PasswordResetRequestResult, +): PasswordResetMutationResult => + result.ok ? undefined : { message: 'Internal Server Error' } + +/** + * A password change, as `ChangePasswordFormContent` reads it. + * + * `invalid_token` is the one failure that survives as itself, because it is the + * one a visitor can act on: the link was wrong, already used, or older than + * thirty minutes, and the answer is to ask for a fresh one. The shared form + * renders it with the `400` copy rather than the generic internal-error copy for + * exactly that reason. + */ +export const changePasswordFormResult = ( + result: ChangePasswordResult, +): ChangePasswordMutationResult => { + if (result.ok) return undefined + + return { + message: + result.reason === 'invalid_token' + ? 'invalid_token' + : 'internal_server_error', + } +} diff --git a/apps/web/src/lib/devices/devices.ts b/apps/web/src/lib/devices/devices.ts new file mode 100644 index 000000000..4cb5b989b --- /dev/null +++ b/apps/web/src/lib/devices/devices.ts @@ -0,0 +1,144 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { DevicesFetcher } from '@vitnode/core/views/auth/settings/devices/devices-query' +import type { + RevokeDevice, + RevokeDeviceArgs, + RevokeDeviceResult, +} from '@vitnode/core/views/auth/settings/devices/devices-revoke' + +import { useQueryClient } from '@tanstack/react-query' +import { createIsomorphicFn } from '@tanstack/react-start' +import { + DEVICES_QUERY_KEY, + devicesQueryOptions, + fetchDevicesInBrowser, +} from '@vitnode/core/views/auth/settings/devices/devices-query' +import { + revokeDeviceInBrowser, + shouldRefreshAfterRevoke, +} from '@vitnode/core/views/auth/settings/devices/devices-revoke' +import React from 'react' + +import { fetchDevicesOnServer } from '#/server/devices.server' + +/** + * The visitor's signed-in devices, as this app's one query definition and one + * revoke. + * + * Everything about *what* the list is - the request, the cache key, what counts + * as a refusal - comes from + * `@vitnode/core/views/auth/settings/devices/devices-query`, which is also what + * the mounted `DevicesContent` is rendered from. This module supplies only the + * two things core cannot know: how to reach the API from a server that is + * rendering a request, and what "refresh the list" means in a router that has a + * query cache instead of `revalidatePath`. + * + * The same shape as `#/lib/files/my-files`, deliberately - see the long note + * there. What is different is only that this list has no parameters, so there is + * one cache entry rather than a family. + */ + +/** + * The transport boundary, and the reason one query definition works in a loader + * and in a component. + * + * Both branches call the Hono API directly - the server one from inside the + * request being rendered, the browser one over the network to the same origin. + * There is deliberately no `createServerFn` in between. A server function is a + * `POST` back to this app that then calls Hono, so a refetch after a revoke would + * cost two round trips for a read the API is already the boundary for. The + * session read in `#/lib/session` *is* a server function, and the difference is + * real rather than stylistic: nothing here needs a `Set-Cookie` copied onto this + * app's own response. + * + * The cookies still travel on both branches, and this read needs two of them. On + * the server `fetcherServer` forwards the whole `Cookie` header the page request + * arrived with; in the browser the call is same-origin, so the browser attaches + * it without being asked. That is what makes a `401` here mean "the session + * ended" rather than "we forgot to say who was asking" - and what makes + * `isCurrent` name the row the reader is actually sitting on. + * + * `createIsomorphicFn` is what makes that safe rather than merely tidy: the Start + * compiler keeps only the branch belonging to the bundle it is building and drops + * the other's import with it, so `devices.server.ts` - and the `server-only` + * marker at the top of it - never reaches the browser. + */ +const fetchDevices: DevicesFetcher = createIsomorphicFn() + .server(fetchDevicesOnServer) + .client(fetchDevicesInBrowser) + +/** + * The devices list, as the one query definition every caller shares. + * + * loader: context.queryClient.ensureQueryData(devicesQuery()) + * component: useSuspenseQuery(devicesQuery()) + * after a revoke: invalidate, and the component above refetches + * + * No `initialData`: the loader has already put the list in the entry this key + * names and the SSR pass dehydrates it, so passing it again would be a second + * copy of the same bytes that can disagree with the first. + */ +export const devicesQuery = () => devicesQueryOptions({ fetchDevices }) + +/** + * Marks the cached devices list stale. + * + * One entry, named exactly - not `queryClient.invalidateQueries()` with no key. + * The session, the messages and every other list this app holds are unaffected by + * a device being signed out, and refetching them because of it is the blunt + * version of the `revalidatePath('/[locale]/(main)', 'layout')` this replaces. + * + * The session entry in particular is deliberately left alone, and that is a + * finding rather than an omission: the API refuses to revoke the current device + * with a `400`, so no revoke this app can perform ends the session it is + * performed from. There is no state in which the cached session is left falsely + * authenticated by a successful revoke. Were that ever to change - were the route + * to start accepting its own device id - this is the function that would have to + * invalidate `SESSION_QUERY_KEY` alongside this one. + * + * Invalidating rather than removing keeps the current rows on screen while the + * fresh ones are fetched, instead of blanking the list under the dialog that is + * still closing. + */ +export const invalidateDevices = async ( + queryClient: QueryClient, +): Promise => + await queryClient.invalidateQueries({ queryKey: DEVICES_QUERY_KEY }) + +/** + * Signs one device out, then refreshes the list if the list is now wrong. + * + * `shouldRefreshAfterRevoke` is core's rule, and the same one the Next.js server + * action applies before it calls `revalidatePath`: a success and a stale row + * (`404`, `400`) make the list wrong, while a `401`, `429` or `500` left it + * exactly as it was. Refetching after one of those would send the same read + * straight back into whatever refused the first - the rate limiter, or an ended + * session - and replace the list the person is reading with an error. + */ +export const revokeDevice = async ( + queryClient: QueryClient, + args: RevokeDeviceArgs, +): Promise => { + const result = await revokeDeviceInBrowser(args) + + if (shouldRefreshAfterRevoke(result)) await invalidateDevices(queryClient) + + return result +} + +/** + * The one callback `DevicesContent` takes, bound to this router's cache. + * + * Memoised, which is the only reason this is a hook rather than a call at the + * point of use: it is a prop on a list that re-renders on every navigation, and a + * new function identity would remount the confirm dialog mid-revoke. + */ +export const useRevokeDeviceCallback = (): RevokeDevice => { + const queryClient = useQueryClient() + + return React.useMemo( + () => async (args: RevokeDeviceArgs) => + await revokeDevice(queryClient, args), + [queryClient], + ) +} diff --git a/apps/web/src/lib/middleware-config.ts b/apps/web/src/lib/middleware-config.ts index ff7c41b21..148ebf27d 100644 --- a/apps/web/src/lib/middleware-config.ts +++ b/apps/web/src/lib/middleware-config.ts @@ -27,6 +27,17 @@ export type MiddlewareConfig = z.infer * * Shared by both transports so a failure looks the same during SSR and after * hydration, rather than the page changing shape when it rehydrates. + * + * ## What it costs the screens that need a captcha + * + * `captcha` is absent here, and it cannot be otherwise - a widget needs a site + * key, and the read that would have supplied one is the read that failed. So on + * a deployment *with* a captcha configured, a registration or reset-password + * form rendered from this fallback shows no widget, submits an empty token, and + * the API answers `400` - which reaches the visitor as the internal-error toast + * rather than as a silent success. Degraded, but not wrong: nothing is created + * and nothing is claimed to have been. The alternative would be inventing a + * configuration, which is how a form ends up looking solved when it is not. */ export const ANONYMOUS_MIDDLEWARE_CONFIG: MiddlewareConfig = Object.freeze({ isEmail: false, diff --git a/apps/web/src/lib/settings/panel.ts b/apps/web/src/lib/settings/panel.ts new file mode 100644 index 000000000..c842365bb --- /dev/null +++ b/apps/web/src/lib/settings/panel.ts @@ -0,0 +1,164 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { SettingsNavKey } from '@vitnode/core/views/auth/settings/settings-nav' + +import { formatPageTitle } from '@vitnode/core/lib/metadata' +import { createTranslator } from 'use-intl' + +import type { Locale } from '#/lib/i18n/shared' + +import { intlQueryOptions } from '#/lib/i18n/query' +import { vitNodeShellConfig } from '#/vitnode.shell.config' + +/** + * What every settings route needs, in one place: the strings, the tab title. + * + * Its own module rather than the layout route's, because three routes and a + * breadcrumb all read the namespace list and a route file importing another route + * file for it would be a cycle. What is here is the part that is identical for + * every panel; what a panel actually renders is the panel's own route. + */ + +/** + * What the settings screens render strings from. + * + * `core.auth.settings` is the heading, the description, the navigation and every + * panel's own title - the same namespace the Next.js layout mounts, kept + * deliberately: the panels are shared components and they look their strings up + * by the same keys in both frameworks. + * + * `core.global` is listed even though the root already provides it, because + * `RouteMessages` mounts its own provider over the root's rather than adding to + * it - so a set that omitted it would take the global strings away from + * everything below. + * + * One list, read by the loader that fetches it, by the provider that mounts it + * and by the breadcrumb, because they have to be the same set or a reader + * suspends on a key nobody warmed. + */ +export const SETTINGS_NAMESPACES = [ + 'core.auth.settings', + 'core.global', +] as const + +/** + * The branch of the message tree these routes read, named for + * `createTranslator`. + * + * The cast this type serves is the same one `files.tsx` explains: the + * translator's key type is derived from the *inferred* type of `messages`, and + * `AbstractIntlMessages` is a bare index signature - so `MessageKeys` cannot tell + * a leaf from a branch and collapses to `never`, making every key a type error. + * Naming the keys these routes read is both the smallest fix and a true + * statement: rename one in `core/locales/en.json` and this stops compiling rather + * than rendering a raw message key into a ``. + */ +interface SettingsMessages { + core: { + auth: { + settings: { + desc: string + nav: { devices: string; overview: string; security: string } + title: string + } + } + } +} + +/** The narrowest slice of a settings route's context the loaders below read. */ +export interface SettingsLoaderContext { + locale: Locale + queryClient: QueryClient +} + +/** + * The settings messages, in the cache, before anything renders in them. + * + * Called by the layout and by each panel. The second call is a cache read rather + * than a second request - same locale, same namespaces, therefore the same key - + * and each panel calls it anyway so that a panel's loader is complete on its own + * terms rather than relying on the order its parent's happened to run in. + */ +const ensureSettingsMessages = async (context: SettingsLoaderContext) => + await context.queryClient.ensureQueryData( + intlQueryOptions({ + locale: context.locale, + namespaces: SETTINGS_NAMESPACES, + }), + ) + +/** + * One panel's tab title, as `"<Panel> - <Settings>"`. + * + * The same two lookups the Next.js pages do - `nav.<key>` and `title` - so both + * frameworks produce the same string, and `formatPageTitle` then appends the + * site name exactly as Next.js does through `title.template`. Translated in the + * loader rather than in `head`, which receives no router context and so cannot + * resolve a locale at all. + */ +export const settingsPanelTitle = ({ + locale, + messages, + navKey, +}: { + locale: Locale + messages: unknown + navKey: SettingsNavKey +}): string => { + const typed = messages as SettingsMessages + const t = createTranslator({ + locale, + messages: typed, + namespace: 'core.auth.settings', + }) + const tNav = createTranslator({ + locale, + messages: typed, + namespace: 'core.auth.settings.nav', + }) + + return `${tNav(navKey)} - ${t('title')}` +} + +/** + * A settings panel's loader: warm the strings, translate its title. + * + * Every panel's loader is this and nothing else, until a panel has data of its + * own to fetch - at which point it awaits this alongside its own read rather + * than replacing it. + */ +export const loadSettingsPanel = async ( + context: SettingsLoaderContext, + navKey: SettingsNavKey, +): Promise<{ title: string }> => { + const intl = await ensureSettingsMessages(context) + + return { + title: settingsPanelTitle({ + locale: context.locale, + messages: intl.messages, + navKey, + }), + } +} + +/** + * A settings panel's `head`, which is a title and deliberately nothing else. + * + * `robots` is **not** here. The settings layout declares `noindex, nofollow` + * once, and TanStack Start merges the `head` of every matched route - so the + * whole subtree inherits it and a panel that restated it would be a second copy + * to keep in step. See `routes/_main/_authenticated/settings.tsx`. + * + * `loaderData` is optional because the router types it so: it is `undefined` + * while the route's loader is still pending, and a `head` that assumed otherwise + * would throw during the first pass of a navigation. + */ +export const settingsPanelHead = (loaderData?: { title: string }) => ({ + meta: loaderData + ? [ + { + title: formatPageTitle(vitNodeShellConfig.metadata, loaderData.title), + }, + ] + : [], +}) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index aa24150c8..413f15c57 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,14 +11,20 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as MainRouteImport } from './routes/_main' import { Route as LoginRouteImport } from './routes/login' +import { Route as RegisterRouteImport } from './routes/register' import { Route as MainIndexRouteImport } from './routes/_main/index' import { Route as MainAuthenticatedRouteImport } from './routes/_main/_authenticated' import { Route as MainDiscoverRouteImport } from './routes/_main/discover' import { Route as MainSearchRouteImport } from './routes/_main/search' import { Route as ApiSplatRouteImport } from './routes/api/$' -import { Route as MainAuthenticatedAccountRouteImport } from './routes/_main/_authenticated/account' +import { Route as LoginResetPasswordRouteImport } from './routes/login_.reset-password' import { Route as MainAuthenticatedFilesRouteImport } from './routes/_main/_authenticated/files' +import { Route as MainAuthenticatedSettingsRouteImport } from './routes/_main/_authenticated/settings' import { Route as LoginSsoProviderIdRouteImport } from './routes/login_.sso.$providerId' +import { Route as MainAuthenticatedSettingsIndexRouteImport } from './routes/_main/_authenticated/settings/index' +import { Route as MainAuthenticatedSettingsDevicesRouteImport } from './routes/_main/_authenticated/settings/devices' +import { Route as MainAuthenticatedSettingsOverviewRouteImport } from './routes/_main/_authenticated/settings/overview' +import { Route as MainAuthenticatedSettingsSecurityRouteImport } from './routes/_main/_authenticated/settings/security' const MainRoute = MainRouteImport.update({ id: '/_main', @@ -29,6 +35,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const RegisterRoute = RegisterRouteImport.update({ + id: '/register', + path: '/register', + getParentRoute: () => rootRouteImport, +} as any) const MainIndexRoute = MainIndexRouteImport.update({ id: '/', path: '/', @@ -53,95 +64,160 @@ const ApiSplatRoute = ApiSplatRouteImport.update({ path: '/api/$', getParentRoute: () => rootRouteImport, } as any) -const MainAuthenticatedAccountRoute = - MainAuthenticatedAccountRouteImport.update({ - id: '/account', - path: '/account', - getParentRoute: () => MainAuthenticatedRoute, - } as any) +const LoginResetPasswordRoute = LoginResetPasswordRouteImport.update({ + id: '/login_/reset-password', + path: '/login/reset-password', + getParentRoute: () => rootRouteImport, +} as any) const MainAuthenticatedFilesRoute = MainAuthenticatedFilesRouteImport.update({ id: '/files', path: '/files', getParentRoute: () => MainAuthenticatedRoute, } as any) +const MainAuthenticatedSettingsRoute = + MainAuthenticatedSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => MainAuthenticatedRoute, + } as any) const LoginSsoProviderIdRoute = LoginSsoProviderIdRouteImport.update({ id: '/login_/sso/$providerId', path: '/login/sso/$providerId', getParentRoute: () => rootRouteImport, } as any) +const MainAuthenticatedSettingsIndexRoute = + MainAuthenticatedSettingsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => MainAuthenticatedSettingsRoute, + } as any) +const MainAuthenticatedSettingsDevicesRoute = + MainAuthenticatedSettingsDevicesRouteImport.update({ + id: '/devices', + path: '/devices', + getParentRoute: () => MainAuthenticatedSettingsRoute, + } as any) +const MainAuthenticatedSettingsOverviewRoute = + MainAuthenticatedSettingsOverviewRouteImport.update({ + id: '/overview', + path: '/overview', + getParentRoute: () => MainAuthenticatedSettingsRoute, + } as any) +const MainAuthenticatedSettingsSecurityRoute = + MainAuthenticatedSettingsSecurityRouteImport.update({ + id: '/security', + path: '/security', + getParentRoute: () => MainAuthenticatedSettingsRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof MainIndexRoute '/login': typeof LoginRoute + '/register': typeof RegisterRoute '/discover': typeof MainDiscoverRoute '/search': typeof MainSearchRoute '/api/$': typeof ApiSplatRoute - '/account': typeof MainAuthenticatedAccountRoute + '/login/reset-password': typeof LoginResetPasswordRoute '/files': typeof MainAuthenticatedFilesRoute + '/settings': typeof MainAuthenticatedSettingsRouteWithChildren '/login/sso/$providerId': typeof LoginSsoProviderIdRoute + '/settings/devices': typeof MainAuthenticatedSettingsDevicesRoute + '/settings/overview': typeof MainAuthenticatedSettingsOverviewRoute + '/settings/security': typeof MainAuthenticatedSettingsSecurityRoute + '/settings/': typeof MainAuthenticatedSettingsIndexRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute + '/register': typeof RegisterRoute '/': typeof MainIndexRoute '/discover': typeof MainDiscoverRoute '/search': typeof MainSearchRoute '/api/$': typeof ApiSplatRoute - '/account': typeof MainAuthenticatedAccountRoute + '/login/reset-password': typeof LoginResetPasswordRoute '/files': typeof MainAuthenticatedFilesRoute '/login/sso/$providerId': typeof LoginSsoProviderIdRoute + '/settings/devices': typeof MainAuthenticatedSettingsDevicesRoute + '/settings/overview': typeof MainAuthenticatedSettingsOverviewRoute + '/settings/security': typeof MainAuthenticatedSettingsSecurityRoute + '/settings': typeof MainAuthenticatedSettingsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_main': typeof MainRouteWithChildren '/login': typeof LoginRoute + '/register': typeof RegisterRoute '/_main/_authenticated': typeof MainAuthenticatedRouteWithChildren '/_main/discover': typeof MainDiscoverRoute '/_main/search': typeof MainSearchRoute '/api/$': typeof ApiSplatRoute + '/login_/reset-password': typeof LoginResetPasswordRoute '/_main/': typeof MainIndexRoute - '/_main/_authenticated/account': typeof MainAuthenticatedAccountRoute '/_main/_authenticated/files': typeof MainAuthenticatedFilesRoute + '/_main/_authenticated/settings': typeof MainAuthenticatedSettingsRouteWithChildren '/login_/sso/$providerId': typeof LoginSsoProviderIdRoute + '/_main/_authenticated/settings/devices': typeof MainAuthenticatedSettingsDevicesRoute + '/_main/_authenticated/settings/overview': typeof MainAuthenticatedSettingsOverviewRoute + '/_main/_authenticated/settings/security': typeof MainAuthenticatedSettingsSecurityRoute + '/_main/_authenticated/settings/': typeof MainAuthenticatedSettingsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/login' + | '/register' | '/discover' | '/search' | '/api/$' - | '/account' + | '/login/reset-password' | '/files' + | '/settings' | '/login/sso/$providerId' + | '/settings/devices' + | '/settings/overview' + | '/settings/security' + | '/settings/' fileRoutesByTo: FileRoutesByTo to: | '/login' + | '/register' | '/' | '/discover' | '/search' | '/api/$' - | '/account' + | '/login/reset-password' | '/files' | '/login/sso/$providerId' + | '/settings/devices' + | '/settings/overview' + | '/settings/security' + | '/settings' id: | '__root__' | '/_main' | '/login' + | '/register' | '/_main/_authenticated' | '/_main/discover' | '/_main/search' | '/api/$' + | '/login_/reset-password' | '/_main/' - | '/_main/_authenticated/account' | '/_main/_authenticated/files' + | '/_main/_authenticated/settings' | '/login_/sso/$providerId' + | '/_main/_authenticated/settings/devices' + | '/_main/_authenticated/settings/overview' + | '/_main/_authenticated/settings/security' + | '/_main/_authenticated/settings/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { MainRoute: typeof MainRouteWithChildren LoginRoute: typeof LoginRoute + RegisterRoute: typeof RegisterRoute ApiSplatRoute: typeof ApiSplatRoute + LoginResetPasswordRoute: typeof LoginResetPasswordRoute LoginSsoProviderIdRoute: typeof LoginSsoProviderIdRoute } @@ -161,6 +237,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/register': { + id: '/register' + path: '/register' + fullPath: '/register' + preLoaderRoute: typeof RegisterRouteImport + parentRoute: typeof rootRouteImport + } '/_main/': { id: '/_main/' path: '/' @@ -196,12 +279,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSplatRouteImport parentRoute: typeof rootRouteImport } - '/_main/_authenticated/account': { - id: '/_main/_authenticated/account' - path: '/account' - fullPath: '/account' - preLoaderRoute: typeof MainAuthenticatedAccountRouteImport - parentRoute: typeof MainAuthenticatedRoute + '/login_/reset-password': { + id: '/login_/reset-password' + path: '/login/reset-password' + fullPath: '/login/reset-password' + preLoaderRoute: typeof LoginResetPasswordRouteImport + parentRoute: typeof rootRouteImport } '/_main/_authenticated/files': { id: '/_main/_authenticated/files' @@ -210,6 +293,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainAuthenticatedFilesRouteImport parentRoute: typeof MainAuthenticatedRoute } + '/_main/_authenticated/settings': { + id: '/_main/_authenticated/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof MainAuthenticatedSettingsRouteImport + parentRoute: typeof MainAuthenticatedRoute + } '/login_/sso/$providerId': { id: '/login_/sso/$providerId' path: '/login/sso/$providerId' @@ -217,17 +307,68 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginSsoProviderIdRouteImport parentRoute: typeof rootRouteImport } + '/_main/_authenticated/settings/': { + id: '/_main/_authenticated/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof MainAuthenticatedSettingsIndexRouteImport + parentRoute: typeof MainAuthenticatedSettingsRoute + } + '/_main/_authenticated/settings/devices': { + id: '/_main/_authenticated/settings/devices' + path: '/devices' + fullPath: '/settings/devices' + preLoaderRoute: typeof MainAuthenticatedSettingsDevicesRouteImport + parentRoute: typeof MainAuthenticatedSettingsRoute + } + '/_main/_authenticated/settings/overview': { + id: '/_main/_authenticated/settings/overview' + path: '/overview' + fullPath: '/settings/overview' + preLoaderRoute: typeof MainAuthenticatedSettingsOverviewRouteImport + parentRoute: typeof MainAuthenticatedSettingsRoute + } + '/_main/_authenticated/settings/security': { + id: '/_main/_authenticated/settings/security' + path: '/security' + fullPath: '/settings/security' + preLoaderRoute: typeof MainAuthenticatedSettingsSecurityRouteImport + parentRoute: typeof MainAuthenticatedSettingsRoute + } } } +interface MainAuthenticatedSettingsRouteChildren { + MainAuthenticatedSettingsDevicesRoute: typeof MainAuthenticatedSettingsDevicesRoute + MainAuthenticatedSettingsOverviewRoute: typeof MainAuthenticatedSettingsOverviewRoute + MainAuthenticatedSettingsSecurityRoute: typeof MainAuthenticatedSettingsSecurityRoute + MainAuthenticatedSettingsIndexRoute: typeof MainAuthenticatedSettingsIndexRoute +} + +const MainAuthenticatedSettingsRouteChildren: MainAuthenticatedSettingsRouteChildren = + { + MainAuthenticatedSettingsDevicesRoute: + MainAuthenticatedSettingsDevicesRoute, + MainAuthenticatedSettingsOverviewRoute: + MainAuthenticatedSettingsOverviewRoute, + MainAuthenticatedSettingsSecurityRoute: + MainAuthenticatedSettingsSecurityRoute, + MainAuthenticatedSettingsIndexRoute: MainAuthenticatedSettingsIndexRoute, + } + +const MainAuthenticatedSettingsRouteWithChildren = + MainAuthenticatedSettingsRoute._addFileChildren( + MainAuthenticatedSettingsRouteChildren, + ) + interface MainAuthenticatedRouteChildren { - MainAuthenticatedAccountRoute: typeof MainAuthenticatedAccountRoute MainAuthenticatedFilesRoute: typeof MainAuthenticatedFilesRoute + MainAuthenticatedSettingsRoute: typeof MainAuthenticatedSettingsRouteWithChildren } const MainAuthenticatedRouteChildren: MainAuthenticatedRouteChildren = { - MainAuthenticatedAccountRoute: MainAuthenticatedAccountRoute, MainAuthenticatedFilesRoute: MainAuthenticatedFilesRoute, + MainAuthenticatedSettingsRoute: MainAuthenticatedSettingsRouteWithChildren, } const MainAuthenticatedRouteWithChildren = @@ -252,7 +393,9 @@ const MainRouteWithChildren = MainRoute._addFileChildren(MainRouteChildren) const rootRouteChildren: RootRouteChildren = { MainRoute: MainRouteWithChildren, LoginRoute: LoginRoute, + RegisterRoute: RegisterRoute, ApiSplatRoute: ApiSplatRoute, + LoginResetPasswordRoute: LoginResetPasswordRoute, LoginSsoProviderIdRoute: LoginSsoProviderIdRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/_main.tsx b/apps/web/src/routes/_main.tsx index b792f724b..fc07f9c4b 100644 --- a/apps/web/src/routes/_main.tsx +++ b/apps/web/src/routes/_main.tsx @@ -19,16 +19,26 @@ import { prefetchSession } from '#/lib/auth/query' * * ## What is deliberately outside it * - * `/login` and `/login/sso/$providerId`. An auth screen is a full-height card on - * an otherwise empty document, and the header it would render is a header whose - * only interesting control is "sign in". Keeping them out is what makes this a - * shell that routes opt into rather than one every route is subject to - and it - * is what `/register` and the password-reset screens will want when they move. + * The four auth screens: `/login`, `/login/sso/$providerId`, `/register` and + * `/login/reset-password`. An auth screen is a full-height card on an otherwise + * empty document, and the header it would render is a header whose only + * interesting control is "sign in". Keeping them out is what makes this a shell + * that routes opt into rather than one every route is subject to. * `routes/api/$` is outside for a different reason: it is a server route and * renders no document at all. * - * Note this is a visual difference from the Next.js app, where `/login` sits - * inside `(main)` and does render the header. + * Note this is a visual difference from the Next.js app, where all four sit + * inside `(main)` and do render the header. + * + * ## What Stage 9 put *under* it + * + * The settings subtree, and that direction is the point: `/settings` and its + * panels are pages on the public site that happen to need a session, so they go + * under this shell and then under `_authenticated`, and inherit the header, the + * breadcrumb area, the `<main>` landmark and the guard from where their files + * live. Nothing in `routes/_main/_authenticated/settings*` renders a header, a + * landmark or a session check of its own. `src/tests/main-shell.test.ts` asserts + * both halves - the settings paths inside, the four auth screens outside. * * ## The slots * diff --git a/apps/web/src/routes/_main/_authenticated.tsx b/apps/web/src/routes/_main/_authenticated.tsx index d0caf498b..7cf57018c 100644 --- a/apps/web/src/routes/_main/_authenticated.tsx +++ b/apps/web/src/routes/_main/_authenticated.tsx @@ -9,10 +9,15 @@ import { canAccessAuthenticatedRoute } from '#/lib/auth/shared' * * Pathless - the leading underscore means it contributes no URL segment - so a * route joins it by *where its file lives*, not by remembering to call a guard: - * `routes/_authenticated/settings.tsx` is `/settings`, guarded, and the guard is - * this file. That is the whole point of introducing it now, with nothing under - * it yet: Stage 8 moves `/settings/*` here and inherits the rule rather than - * writing a second copy of it. + * `routes/_main/_authenticated/settings.tsx` is `/settings`, guarded, and the + * guard is this file. That is what it buys, and Stage 9 is where it pays: the + * settings layout and its four panels moved under here and inherited the rule + * rather than writing a second copy of it, and neither the layout nor any panel + * contains the word "session". + * + * Three pages sit under it today - `/files`, `/settings` and the settings + * subtree - and none of them checks a session. That is the invariant + * `src/tests/settings-routes.test.ts` pins by scanning for the absence. * * ## Why the check is in `beforeLoad` * diff --git a/apps/web/src/routes/_main/_authenticated/account.tsx b/apps/web/src/routes/_main/_authenticated/account.tsx deleted file mode 100644 index 30cbef011..000000000 --- a/apps/web/src/routes/_main/_authenticated/account.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router' -import { Button } from '@vitnode/core/components/ui/button' -import { formatPageTitle } from '@vitnode/core/lib/metadata' -import { useTranslations } from 'use-intl' - -import { useSignOutAction } from '#/lib/auth/actions' -import { vitNodeShellConfig } from '#/vitnode.shell.config' - -/** - * The Stage 6 verification page, and nothing more. - * - * The sibling of `routes/index.tsx`, which has served the same purpose since - * Stage 3: a page whose only job is to make the stage's runtime observable. No - * VitNode account feature is migrated here - `/settings` and its three tabs are - * still the Next.js app's, and Stage 8 moves them under this same - * `_authenticated` boundary. - * - * It exists for three reasons, and each one is a thing that would otherwise be - * unprovable until Stage 8: - * - * 1. **The guard has something to guard.** `_authenticated` is a pathless - * layout, and the route generator refuses a childless one - it infers `/` for - * it, which collides with the front page. So the boundary needs a first - * child, and a page that renders the session it was let in with is the - * smallest honest one. - * 2. **The redirect is real.** Anonymous, this URL answers - * `/login?returnTo=/account` - no protected markup is rendered first, because - * the decision is made in `beforeLoad`. - * 3. **Sign-out is wired.** This app mounts no header yet, so there is nowhere - * else a sign-out control could live without migrating the shell. The button - * below is the narrow alternative: it ends the session, replaces the cached - * one, and lets the guard above notice - which lands the visitor back on the - * login page, from the rule that owns that decision rather than from anything - * here. - * - * Delete it when a real account page arrives. - */ -export const Route = createFileRoute('/_main/_authenticated/account')({ - // No loader and no `RouteMessages`: everything this page renders comes from - // `core.global`, which the root route already warms and provides. - head: () => ({ - meta: [{ title: formatPageTitle(vitNodeShellConfig.metadata, 'Account') }], - }), - component: AccountRoute, -}) - -function AccountRoute() { - /** - * The visitor, from the guard that let this page render. - * - * `context.auth` is `_authenticated`'s `beforeLoad` return, already narrowed - * to the signed-in half of the union - so `auth.user` needs no check here. It - * is the same object the guard decided on, read from the one canonical session - * entry, so this page cannot disagree with the rule that admitted it. - */ - const { auth } = Route.useRouteContext() - const t = useTranslations('core.global') - const signOut = useSignOutAction() - - return ( - <div className="mx-auto flex w-full max-w-2xl flex-col gap-6 p-6"> - <header className="flex flex-col gap-2"> - <h1 className="text-3xl font-semibold tracking-tight text-balance"> - {auth.user.name} - </h1> - - <p className="text-muted-foreground leading-relaxed text-pretty"> - Behind the <code>_authenticated</code> boundary. Stage 8 moves - <code> /settings</code> here; this page is the scaffold that proves - the guard and the sign-out transition. - </p> - </header> - - <section className="bg-card text-card-foreground flex flex-col gap-4 rounded-lg border p-6"> - <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> - <span className="text-muted-foreground text-sm leading-relaxed"> - Email - </span> - <span className="text-sm" data-testid="account-email"> - {auth.user.email} - </span> - </div> - - <div className="flex flex-col gap-2 border-t pt-4 sm:flex-row sm:items-center sm:justify-between"> - <span className="text-muted-foreground text-sm leading-relaxed"> - Session - ends here, and the guard above notices - </span> - - <Button - onClick={async () => { - // No `router.invalidate()` here: `useSignOutAction` already does - // it, and the guard above is what notices. - await signOut() - }} - variant="outline" - > - {t('user_bar.log_out')} - </Button> - </div> - </section> - </div> - ) -} diff --git a/apps/web/src/routes/_main/_authenticated/settings.tsx b/apps/web/src/routes/_main/_authenticated/settings.tsx new file mode 100644 index 000000000..701840a5d --- /dev/null +++ b/apps/web/src/routes/_main/_authenticated/settings.tsx @@ -0,0 +1,166 @@ +import { createFileRoute, Outlet, useRouterState } from '@tanstack/react-router' +import { SettingsNavContent } from '@vitnode/core/views/auth/settings/nav-content' +import { isSettingsRootPath } from '@vitnode/core/views/auth/settings/settings-nav' +import { SettingsShellContent } from '@vitnode/core/views/auth/settings/shell-content' + +import { SettingsBreadcrumb } from '#/components/layout/settings-breadcrumb' +import { MigrationLink } from '#/components/migration-link' +import { RouteMessages } from '#/components/route-messages' +import { intlQueryOptions } from '#/lib/i18n/query' +import { SETTINGS_NAMESPACES } from '#/lib/settings/panel' + +/** + * The settings screens' own layout - the heading, the navigation card, and the + * panel every settings page renders inside. + * + * A real nested layout route rather than a wrapper each page remembers to + * render: `settings.tsx` alongside a `settings/` directory makes this the parent + * of `/settings`, `/settings/overview` and `/settings/security`, so a panel joins + * the frame by *where its file lives*. That is the same rule `_main` uses for the + * application shell and `_authenticated` for the session guard, and it is what + * keeps the frame from being copied into three (soon four) route files that would + * then drift. + * + * One route file serving two public URL shapes: `/settings/security` and + * `/pl/settings/security` both match here, because the locale is stripped before + * matching and written back into every link the router builds (`rewrite` in + * `src/router.tsx`). Nothing in this subtree mentions a language. + * + * ## Where it sits, and what that buys + * + * Under `_main` for the shell and under `_authenticated` for the guard. There is + * deliberately **no session check in this subtree**: the Next.js layout opens with + * `getSessionApi()` and `notFound()` because it has nowhere else to put the rule, + * and here that rule is `routes/_main/_authenticated.tsx`, running in + * `beforeLoad` before any of this renders. An anonymous visitor to + * `/settings/security` is answered with `/login?returnTo=/settings/security` - + * no locale in the round-trip value, because the rewrite writes that back on the + * way home - and receives no byte of a settings page. + * + * A second check here would not be defence in depth, it would be a second rule to + * keep in step with the first. The actual boundary is neither: every settings + * read and write is authorized by Hono from the session cookie in the API's own + * handlers, which is what a future panel's data will rely on. + * + * ## What it owns, so that no panel does + * + * The `container`, the `<h1>` 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 `<Outlet />`, and the `<Outlet />` 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: <SettingsBreadcrumb /> }, +}) + +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 ( + <RouteMessages namespaces={SETTINGS_NAMESPACES}> + <SettingsShellContent + BackLink={MigrationLink} + isRoot={isSettingsRootPath(pathname)} + /* + `MigrationLink` rather than the router's `Link`, and it is worth saying + why now that it makes no difference: every panel the menu lists is + migrated, so every entry resolves to an ordinary client-side navigation. + What this keeps is the *rule* - ask the route tree per href, and load the + Next.js app for a destination this one does not serve. A panel added to + `SETTINGS_NAV_ITEMS` before its route exists then degrades to a document + load into the application that does serve it, rather than to a TanStack + not-found. Neither this file nor the shared nav model holds a list of + which is which; `src/tests/settings-routes.test.ts` asserts that today + the answer is "owned" for all of them. + */ + nav={ + <SettingsNavContent + LinkComponent={MigrationLink} + pathname={pathname} + /> + } + > + <Outlet /> + </SettingsShellContent> + </RouteMessages> + ) +} 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..2861b216e --- /dev/null +++ b/apps/web/src/routes/_main/_authenticated/settings/devices.tsx @@ -0,0 +1,159 @@ +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 `<h1>`, 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()) + * component: useSuspenseQuery(devicesQuery()) + * 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. + * + * ## 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 }) => { + const [panel] = await Promise.all([ + loadSettingsPanel(context, 'devices'), + context.queryClient.ensureQueryData(devicesQuery()), + ]) + + return panel + }, + /** + * 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 `<Suspense fallback={<DevicesListSkeleton />}>` 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 `<Outlet />` - 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 `<Outlet />` 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: <SettingsBreadcrumb navKey="devices" /> }, +}) + +/** + * The panel heading, which both states below render identically. + * + * `core.auth.settings.devices.title` and `.desc` - the panel's own `<h2>`, not + * the settings `<h1>` 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 <HeaderContent desc={t('desc')} h2={t('title')} /> +} + +function DevicesPending() { + return ( + <> + <DevicesHeading /> + <DevicesListSkeleton /> + </> + ) +} + +function DevicesRoute() { + const { data } = useSuspenseQuery(devicesQuery()) + const onRevoke = useRevokeDeviceCallback() + + return ( + <> + <DevicesHeading /> + + {/* + 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. + */} + <DevicesContent devices={data.devices} onRevoke={onRevoke} /> + </> + ) +} 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: <SettingsBreadcrumb navKey="overview" /> }, + }, +) 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: <SettingsBreadcrumb navKey="security" /> }, + }, +) diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 3a4bd1f61..e1e9579ed 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. */ /** 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..d4760fd16 --- /dev/null +++ b/apps/web/src/routes/login_.reset-password.tsx @@ -0,0 +1,266 @@ +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 { + hasPasswordRecovery, + normalizePasswordResetSearch, + 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. + * + * 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. + * + * ## 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 not-found boundary before the stream + * opens and answers **404** (`applyFailure` in `@tanstack/router-core`), so the + * status is right without this route setting one by hand. + * + * `hasPasswordRecovery` also reads `false` when the configuration could not be + * read at all - see its own note, which owns that trade-off. + */ + beforeLoad: async ({ context }) => { + const config = await context.queryClient.ensureQueryData( + middlewareConfigQueryOptions(), + ) + + // TanStack Router's own control-flow signal, like `redirect()`. + // eslint-disable-next-line @typescript-eslint/only-throw-error + if (!hasPasswordRecovery(config)) 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 ( + <main> + <ErrorContent + actions={<ErrorActions />} + code={404} + description={t('errors.404.desc')} + title={t('errors.404.title')} + /> + </main> + ) +} + +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 ( + <RouteMessages namespaces={namespaces}> + <main> + <PasswordResetContent> + {mode.mode === 'change' ? ( + <ChangePasswordFormContent + link={mode.link} + onChanged={() => { + 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. + */ + <PasswordResetFormContent + captcha={config.captcha} + onRequestReset={requestPasswordResetAction} + /> + )} + </PasswordResetContent> + </main> + </RouteMessages> + ) +} 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 ( - <> - <Button - onClick={() => { - router.history.back() - }} - size="lg" - variant="ghost" - > - <ArrowLeft /> - {t('go_back')} - </Button> - - <MigrationLink className={cn(buttonVariants({ size: 'lg' }))} href="/"> - <HomeIcon /> - {t('back_home')} - </MigrationLink> - </> - ) -} - function SsoCallbackRoute() { const { providerId } = Route.useParams() const search = Route.useSearch() @@ -180,7 +144,7 @@ function SsoCallbackRoute() { <RouteMessages namespaces={CALLBACK_NAMESPACES}> <main> <SSOCallbackContent - errorActions={<CallbackErrorActions />} + errorActions={<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..41de90fc7 --- /dev/null +++ b/apps/web/src/routes/register.tsx @@ -0,0 +1,228 @@ +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 `<main>`, 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 `<main>`, 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 `"<page> - <site>"` 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() { + 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 ( + <RouteMessages namespaces={REGISTER_NAMESPACES}> + <main> + <SignUpContent + form={ + <SignUpFormContent + captcha={config.captcha} + isEmail={config.isEmail} + LinkComponent={MigrationLink} + onSignUp={signUp} + /> + } + LinkComponent={MigrationLink} + sso={ + <SSOButtonsContent + onSelectProvider={startSsoAction} + providers={ssoProvidersOf(config)} + /> + } + /> + </main> + </RouteMessages> + ) +} 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<unknown> => { + 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<string> => { + 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<string, string> => + 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<SignUpResult> => { + 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<PasswordResetRequestResult> => { + 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<ChangePasswordResult> => { + 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/tests/auth-routes.test.ts b/apps/web/src/tests/auth-routes.test.ts new file mode 100644 index 000000000..ea092fe23 --- /dev/null +++ b/apps/web/src/tests/auth-routes.test.ts @@ -0,0 +1,262 @@ +import { + defaultParseSearch, + defaultStringifySearch, +} from '@tanstack/react-router' +import { describe, expect, it } from 'vitest' + +import { + hasPasswordRecovery, + normalizePasswordResetSearch, + passwordResetMode, + passwordResetNamespaces, +} from '#/lib/auth/password-reset-route' +import { postAuthDestination } from '#/lib/auth/redirects' +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) + }) +}) + +describe('whether this deployment has password recovery at all', () => { + it('follows the email adapter', () => { + expect(hasPasswordRecovery({ isEmail: true })).toBe(true) + expect(hasPasswordRecovery({ isEmail: false })).toBe(false) + }) +}) + +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..2f14150dc --- /dev/null +++ b/apps/web/src/tests/devices-route.test.ts @@ -0,0 +1,169 @@ +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 { DEVICES_QUERY_KEY } 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<typeof DevicesRevokeModule>()), + revokeDeviceInBrowser: async () => Promise.resolve(nextRevokeResult), + }), +) + +const { devicesQuery, invalidateDevices, revokeDevice } = + await import('#/lib/devices/devices') + +/** 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().queryKey, { devices: [] }) + queryClient.setQueryData(SESSION_KEY, { user: { id: 1 } }) + 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().queryKey)).toBe(hashKey(DEVICES_QUERY_KEY)) + }) + + 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().queryKey).toEqual(['devices', 'me']) + }) + + it('asks once, so a 429 is not answered by two more requests', () => { + expect(devicesQuery().retry).toBe(false) + }) +}) + +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) + + expect(isStale(queryClient, DEVICES_QUERY_KEY)).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) + + 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) + + expect(queryClient.getQueryData(DEVICES_QUERY_KEY)).toBeDefined() + }) + + 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, { 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, { publicId: 'a1b2c3' }) + + expect(isStale(queryClient, DEVICES_QUERY_KEY)).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, { publicId: 'a1b2c3' }) + + expect(isStale(queryClient, DEVICES_QUERY_KEY)).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, { publicId: 'a1b2c3' }) + + expect(isStale(queryClient, DEVICES_QUERY_KEY)).toBe(false) + }, + ) + + it('returns the finite result to the caller either way', async () => { + const queryClient = seed() + nextRevokeResult = { error: { status: 429 } } + + expect(await revokeDevice(queryClient, { 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/<code>` 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/isolation.test.ts b/apps/web/src/tests/isolation.test.ts index 620893352..4f17cfb58 100644 --- a/apps/web/src/tests/isolation.test.ts +++ b/apps/web/src/tests/isolation.test.ts @@ -362,9 +362,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 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 + // `<main>` 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 `<main>` is a document with no main landmark at * all. */ - it.each(['login.tsx', 'login_.sso.$providerId.tsx'])( - '%s renders exactly one <main> 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 <main> 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/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/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/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<ReturnType<typeof getDevicesApi>>; 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 ( + <> + <CardHeader className="text-center"> + <CardTitle> + <h1>{t("title")}</h1> + </CardTitle> + <CardDescription>{t("desc")}</CardDescription> + </CardHeader> + + <CardContent> + <AutoForm + fields={[ + { + id: "password", + component: props => ( + <PasswordInput label={tSignUp("password.label")} {...props} /> + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + className: "w-full", + children: t("submit"), + }} + /> + </CardContent> + </> + ); +}; 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 ( - <> - <CardHeader className="text-center"> - <CardTitle> - <h1>{t("title")}</h1> - </CardTitle> - <CardDescription>{t("desc")}</CardDescription> - </CardHeader> - - <CardContent> - <AutoForm - fields={[ - { - id: "password", - component: props => ( - <PasswordInput label={tSignUp("password.label")} {...props} /> - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - className: "w-full", - children: t("submit"), - }} - /> - </CardContent> - </> + <ChangePasswordFormContent + link={link} + onChanged={() => { + 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<typeof zodChangePasswordSchema>) => { +}: ChangePasswordSubmitValues): Promise<ChangePasswordMutationResult> => { 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<ChangePasswordFormSchema>; + +/** + * 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<ChangePasswordMutationResult>; + +/** + * 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<ChangePasswordFormSchema> = 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<typeof ChangePasswordForm>) => { - 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<typeof formSchema>) => { - 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 ( - <> - <CardHeader className="flex flex-col items-center text-center"> - <div className="mb-3 rounded-2xl border p-3"> - <MailCheckIcon className="size-8" /> - </div> - <CardTitle className="text-balance"> - {t("confirmation.title")} - </CardTitle> - <CardDescription className="text-pretty"> - {t("confirmation.desc")} - </CardDescription> - </CardHeader> - - <CardContent className="space-y-2"> - <Label htmlFor="email">{tSignUp("email.label")}</Label> - <Input className="w-full" id="email" readOnly value={email} /> - </CardContent> - - <CardFooter> - <CardDescription>{t("confirmation.check_spam")}</CardDescription> - </CardFooter> - </> - ); -} +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<typeof routeMiddlewareSchema>["captcha"]; -}) => { - const { formSchema, onSubmit, sentEmail } = useForm(); - const t = useTranslations("core.auth.reset_password"); - const tSignUp = useTranslations("core.auth.sign_up"); - - if (sentEmail) { - return <ConfirmationView email={sentEmail} />; - } - - return ( - <> - <CardHeader className="text-center"> - <CardTitle> - <h1>{t("title")}</h1> - </CardTitle> - <CardDescription>{t("desc")}</CardDescription> - </CardHeader> - - <CardContent> - <AutoForm - captcha={captcha} - fields={[ - { - id: "email", - component: props => ( - <AutoFormInput {...props} label={tSignUp("email.label")} /> - ), - }, - ]} - formSchema={formSchema} - onSubmit={onSubmit} - submitButtonProps={{ - className: "w-full", - children: t("submit"), - }} - /> - </CardContent> - </> - ); -}; +}) => ( + <PasswordResetFormContent captcha={captcha} onRequestReset={mutationApi} /> +); 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<PasswordResetMutationResult> => { 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 ( + <> + <CardHeader className="flex flex-col items-center text-center"> + <div className="mb-3 rounded-2xl border p-3"> + <MailCheckIcon className="size-8" /> + </div> + <CardTitle className="text-balance"> + {t("confirmation.title")} + </CardTitle> + <CardDescription className="text-pretty"> + {t("confirmation.desc")} + </CardDescription> + </CardHeader> + + <CardContent className="space-y-2"> + <Label htmlFor="email">{tSignUp("email.label")}</Label> + <Input className="w-full" id="email" readOnly value={email} /> + </CardContent> + + <CardFooter> + <CardDescription>{t("confirmation.check_spam")}</CardDescription> + </CardFooter> + </> + ); +}; + +/** + * 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<typeof routeMiddlewareSchema>["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 <ConfirmationView email={sentEmail} />; + } + + return ( + <> + <CardHeader className="text-center"> + <CardTitle> + <h1>{t("title")}</h1> + </CardTitle> + <CardDescription>{t("desc")}</CardDescription> + </CardHeader> + + <CardContent> + <AutoForm + captcha={captcha} + fields={[ + { + id: "email", + component: props => ( + <AutoFormInput {...props} label={tSignUp("email.label")} /> + ), + }, + ]} + formSchema={formSchema} + onSubmit={onSubmit} + submitButtonProps={{ + className: "w-full", + children: t("submit"), + }} + /> + </CardContent> + </> + ); +}; 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<PasswordResetFormSchema>; + +/** 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<typeof formSchema> = 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<PasswordResetMutationResult>; + +/** + * 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<PasswordResetFormSchema> = 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 `<Suspense>` boundary inside. + */ +export const PasswordResetContent = ({ + children, +}: { + children: React.ReactNode; +}) => ( + <div className="mx-auto flex max-w-md flex-col justify-center px-4 py-16 md:min-h-[calc(100vh-4rem)]"> + <Card>{children}</Card> + </div> +); + +/** Either form's shape while the deployment configuration is still in flight. */ +export const PasswordResetSkeleton = () => ( + <> + <CardHeader className="flex flex-col items-center space-y-2 text-center"> + <Skeleton className="h-6 w-48" /> + <Skeleton className="h-4 w-64" /> + </CardHeader> + + <CardContent className="space-y-2"> + <Skeleton className="h-4 w-24" /> + <Skeleton className="h-9 w-full" /> + <Skeleton className="mt-4 h-9 w-full" /> + </CardContent> + </> +); 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<typeof routeMiddlewareSchema>["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 ( <I18nProvider namespaces={["core.auth.sign_up", "core.auth.change_password"]} > - <ChangePasswordForm token={token} userId={userId} /> + <ChangePasswordForm link={link} /> </I18nProvider> ); } @@ -43,36 +55,22 @@ const PasswordResetContent = async ({ ); }; -const PasswordResetContentSkeleton = () => ( - <> - <CardHeader className="flex flex-col items-center space-y-2 text-center"> - <Skeleton className="h-6 w-48" /> - <Skeleton className="h-4 w-64" /> - </CardHeader> - - <CardContent className="space-y-2"> - <Skeleton className="h-4 w-24" /> - <Skeleton className="h-9 w-full" /> - <Skeleton className="mt-4 h-9 w-full" /> - </CardContent> - </> -); - 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 ( - <div className="mx-auto flex max-w-md flex-col justify-center px-4 py-16 md:min-h-[calc(100vh-4rem)]"> - <Card> - <React.Suspense fallback={<PasswordResetContentSkeleton />}> - <PasswordResetContent captcha={captcha} searchParams={searchParams} /> - </React.Suspense> - </Card> - </div> + <PasswordResetContent> + <React.Suspense fallback={<PasswordResetSkeleton />}> + <PasswordResetRouteContent + captcha={captcha} + searchParams={searchParams} + /> + </React.Suspense> + </PasswordResetContent> ); }; 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<typeof recoveryLinkSchema>; + +/** + * 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: <DateFormat date={expiresAt} showFullDate />, + value: <DateFormat date={device.expiresAt} showFullDate />, }, ]; @@ -48,15 +61,27 @@ export const DeviceItem = async ({ <div className="min-w-0 flex-1 space-y-1"> <div className="flex flex-wrap items-center gap-2"> - <span className="font-semibold">{os}</span> - {isCurrent && <Badge>{t("current_device")}</Badge>} + <span className="font-semibold">{device.os}</span> + {device.isCurrent && <Badge>{t("current_device")}</Badge>} </div> <p className="text-muted-foreground text-sm"> - {t("last_active")}: <DateFormat date={lastSeen} /> + {t("last_active")}: <DateFormat date={device.lastSeen} /> </p> </div> - {!isCurrent && <RevokeDeviceButton os={os} publicId={publicId} />} + {/* + 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) && ( + <RevokeDeviceButton + onRevoke={onRevoke} + os={device.os} + publicId={device.publicId} + /> + )} </div> <Separator className="my-4" /> 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<string, string[]> => { + const found = new Map<string, string[]>(); + const parents = new Map<string, string>(); + const seen = new Set<string>(); + + 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 `<Suspense>` 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 <p className="text-muted-foreground text-sm">{t("empty")}</p>; + } + + return ( + <div className="flex flex-col gap-4"> + {devices.map(device => ( + <DeviceItem device={device} key={device.publicId} onRevoke={onRevoke} /> + ))} + </div> + ); +}; 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 <p className="text-muted-foreground text-sm">{t("empty")}</p>; + if (res.status !== 200) { + return notFound(); } - return ( - <div className="space-y-4"> - {devices.map(device => ( - <DeviceItem key={device.publicId} {...device} /> - ))} - </div> - ); + const { devices } = await res.json(); + + return <DevicesContent devices={devices} onRevoke={revokeDeviceAction} />; }; 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..da30a30c1 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts @@ -0,0 +1,216 @@ +import { hashKey } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; + +import { + DEVICE_TYPES, + DEVICES_QUERY_KEY, + 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, one cache entry", () => { + it("is keyed by nothing at all, because the request is", () => { + expect(DEVICES_QUERY_KEY).toEqual(["devices", "me"]); + }); + + 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().queryKey)).toBe( + hashKey( + devicesQueryOptions({ + fetchDevices: async () => Promise.resolve({ devices: [] }), + }).queryKey, + ), + ); + }); + + 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(DEVICES_QUERY_KEY[0]).not.toBe("vitnode"); + }); + + it("asks once, because every failure it can have is worse when repeated", () => { + expect(devicesQueryOptions().retry).toBe(false); + }); +}); + +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..5c46525fd --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts @@ -0,0 +1,245 @@ +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<typeof usersModule>( + 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. That is also why the cache key below has nothing + * in it. + */ +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<DevicesApi>; + +/** 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 this list reads and writes, and the root an invalidation + * names. + * + * One key with nothing in it, because the request has nothing in it: the route + * takes no parameters and answers with whichever user the cookie identifies. A + * user id here would be a second source of truth for something the cookie + * already decides, and the QueryClient it lives in is per request on the server + * and per browser on the client, so there is no client holding two visitors' + * lists. + * + * 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. + * + * Exported as the invalidation target too - there is exactly one entry, so the + * key and the family are the same value. + */ +export const DEVICES_QUERY_KEY = ["devices", "me"] 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 })) + * + * and the component reads the very same options back: + * + * const { data } = useSuspenseQuery(devicesQuery()) + * + * Same key, same request, same status checking - so the loader's list is the + * list the component renders, and a revoke that invalidates + * {@link DEVICES_QUERY_KEY} refetches through the identical contract. + * + * `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, +}: { + fetchDevices?: DevicesFetcher; +} = {}) => + queryOptions({ + queryFn: async () => await fetchDevices(), + queryKey: DEVICES_QUERY_KEY, + 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<typeof devicesQueryOptions>; 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<RevokeDeviceResult>; + +/** + * 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<Device, "isCurrent">): 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 = ({ <ConfirmActionAlertDialog description={t("desc", { os })} onSubmit={async ({ onClose }) => { - 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<SettingsNavKey, React.ComponentType> = { + devices: MonitorSmartphoneIcon, + overview: UserRoundIcon, + security: KeyRoundIcon, +}; + +export const SettingsNavContent = ({ + LinkComponent, + pathname, +}: { + LinkComponent: AuthLinkComponent; + pathname: string; +}) => { + const t = useTranslations("core.auth.settings.nav"); + + return ( + <nav className="flex flex-col gap-1"> + {SETTINGS_NAV_ITEMS.map(item => { + const Icon = ICONS[item.key]; + const isActive = isSettingsNavItemActive(item, pathname); + + return ( + <LinkComponent + aria-current={isActive ? "page" : undefined} + className={cn( + buttonVariants({ variant: isActive ? "default" : "ghost" }), + "w-full justify-start gap-2", + )} + href={item.href} + key={item.href} + > + <Icon /> + {t(item.key)} + <ChevronRightIcon className="ml-auto opacity-60 sm:hidden" /> + </LinkComponent> + ); + })} + </nav> + ); +}; 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 ( - <nav className="flex flex-col gap-1"> - {items.map(({ href, key, icon: Icon, ...item }) => { - const aliases = "aliases" in item ? item.aliases : []; - const isActive = [href, ...aliases].some( - url => pathname === normalizeUrl(url), - ); - - return ( - <Link - aria-current={isActive ? "page" : undefined} - className={cn( - buttonVariants({ variant: isActive ? "default" : "ghost" }), - "w-full justify-start gap-2", - )} - href={href} - key={href} - > - <Icon /> - {t(key)} - <ChevronRightIcon className="ml-auto opacity-60 sm:hidden" /> - </Link> - ); - })} - </nav> - ); -}; +/** + * {@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 = () => ( + <SettingsNavContent LinkComponent={NextAuthLink} pathname={usePathname()} /> +); 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 <HeaderContent h2={t("overview")} />; }; 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 <HeaderContent h2={t("security")} />; }; 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 ( + <div className="container mx-auto space-y-6 px-4"> + <HeaderContent + className={cn(!isRoot && "hidden md:flex")} + desc={t("desc")} + h1={t("title")} + /> + + <div className="flex flex-col items-start gap-6 md:flex-row"> + <Card + className={cn( + "w-full md:w-80 md:shrink-0", + !isRoot && "hidden md:flex", + )} + > + <CardContent>{nav}</CardContent> + </Card> + + <Card + className={cn("w-full min-w-0 flex-1", isRoot && "hidden md:flex")} + > + <CardContent> + <BackLink + className={cn( + buttonVariants({ size: "sm", variant: "ghost" }), + "mb-4 w-full justify-start gap-2 p-0 md:hidden", + )} + href={SETTINGS_ROOT_HREF} + > + <ArrowLeftIcon /> + {t("title")} + </BackLink> + + {children} + </CardContent> + </Card> + </div> + </div> + ); +}; 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 ( - <div className="container mx-auto space-y-6 px-4"> - <HeaderContent - className={cn(!isRoot && "hidden md:flex")} - desc={t("desc")} - h1={t("title")} - /> - - <div className="flex flex-col items-start gap-6 md:flex-row"> - <Card - className={cn( - "w-full md:w-80 md:shrink-0", - !isRoot && "hidden md:flex", - )} - > - <CardContent> - <NavSettings /> - </CardContent> - </Card> - - <Card - className={cn("w-full min-w-0 flex-1", isRoot && "hidden md:flex")} - > - <CardContent> - <Link - className={cn( - buttonVariants({ size: "sm", variant: "ghost" }), - "mb-4 w-full justify-start gap-2 p-0 md:hidden", - )} - href="/settings" - > - <ArrowLeftIcon /> - {t("title")} - </Link> - - {children} - </CardContent> - </Card> - </div> - </div> - ); -}; +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 }) => ( + <SettingsShellContent + BackLink={NextAuthLink} + isRoot={isSettingsRootPath(usePathname())} + nav={<NavSettings />} + > + {children} + </SettingsShellContent> +); 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<typeof routeMiddlewareSchema>["captcha"]; isEmail: boolean; -}) => { - const t = useTranslations("core.auth.sign_up"); - const { onSubmit, formSchema } = useFormSignUp(); - - return ( - <AutoForm - captcha={captcha} - fields={[ - { - id: "name", - component: ({ field, ...props }) => { - const value: string = field.value ?? ""; - - return ( - <div className="space-y-2"> - <AutoFormInput - field={field} - label={t("username.label")} - {...props} - /> - {value.length >= 3 && ( - <div className="text-muted-foreground text-sm"> - {t.rich("username.your_user_code", { - code: () => ( - <span className="text-foreground"> - {removeSpecialCharacters(value)} - </span> - ), - })} - </div> - )} - </div> - ); - }, - }, - { - id: "email", - component: props => ( - <AutoFormInput label={t("email.label")} {...props} /> - ), - }, - { - id: "password", - component: props => ( - <PasswordInput label={t("password.label")} {...props} /> - ), - }, - { - id: "terms", - component: props => ( - <AutoFormCheckbox - {...props} - description={t.rich("terms.desc", { - link: text => ( - <Link className="text-primary" href="/terms"> - {text} - </Link> - ), - })} - label={t("terms.label")} - /> - ), - }, - ...(isEmail - ? [ - { - id: "newsletter" as const, - component: (props: ItemAutoFormComponentProps) => ( - <AutoFormCheckbox - {...props} - description={t("newsletter.desc")} - label={t("newsletter.label")} - /> - ), - }, - ] - : []), - ]} - formSchema={formSchema} - mode="all" - onSubmit={onSubmit} - submitButtonProps={{ - className: "w-full", - children: t("submit"), - }} - /> - ); -}; +}) => ( + <SignUpFormContent + captcha={captcha} + isEmail={isEmail} + LinkComponent={NextAuthLink} + onSignUp={mutationApi} + /> +); 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<typeof zodSignUpSchema> & { captchaToken: string }) => { +}: SignUpSubmitValues): Promise<SignUpMutationResult> => { 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<typeof createSignUpFormSchema>; +export type SignUpFormValues = z.infer<SignUpFormSchema>; + +/** + * 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<typeof routeMiddlewareSchema>["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 ( + <AutoForm + captcha={captcha} + fields={[ + { + id: "name", + component: ({ field, ...props }) => { + const value: string = field.value ?? ""; + + return ( + <div className="space-y-2"> + <AutoFormInput + field={field} + label={t("username.label")} + {...props} + /> + {value.length >= 3 && ( + <div className="text-muted-foreground text-sm"> + {t.rich("username.your_user_code", { + code: () => ( + <span className="text-foreground"> + {removeSpecialCharacters(value)} + </span> + ), + })} + </div> + )} + </div> + ); + }, + }, + { + id: "email", + component: props => ( + <AutoFormInput label={t("email.label")} {...props} /> + ), + }, + { + id: "password", + component: props => ( + <PasswordInput label={t("password.label")} {...props} /> + ), + }, + { + id: "terms", + component: props => ( + <AutoFormCheckbox + {...props} + description={t.rich("terms.desc", { + link: text => ( + <LinkComponent className="text-primary" href={termsHref}> + {text} + </LinkComponent> + ), + })} + label={t("terms.label")} + /> + ), + }, + ...(isEmail + ? [ + { + id: "newsletter" as const, + component: (props: ItemAutoFormComponentProps) => ( + <AutoFormCheckbox + {...props} + description={t("newsletter.desc")} + label={t("newsletter.label")} + /> + ), + }, + ] + : []), + ]} + 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 = () => ( + <div className="space-y-8"> + {[0, 1, 2].map(field => ( + <div className="space-y-2" key={field}> + <Skeleton className="h-4 w-24" /> + <Skeleton className="h-9 w-full" /> + </div> + ))} + + <div className="flex gap-2"> + <Skeleton className="size-4 shrink-0" /> + <Skeleton className="h-4 w-48" /> + </div> + + <Skeleton className="h-9 w-full" /> + </div> +); 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<typeof formSchema> = 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<SignUpMutationResult>; + +/** + * 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<SignUpFormSchema> = 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 `<Suspense>` by `SignUpView`. + */ +export const SignUpCard = ({ + form, + sso, +}: { + form: React.ReactNode; + sso?: React.ReactNode; +}) => <SignUpContent form={form} LinkComponent={NextAuthLink} sso={sso} />; 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 + * `<Suspense>`; 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 ( + <div className="mx-auto flex max-w-md flex-col justify-center px-4 py-16 md:min-h-[calc(100vh-4rem)]"> + <WrapperSignUp> + <Card className="bg-muted gap-0 p-0"> + <div className="bg-card rounded-xl p-6"> + <div className="mb-10 space-y-2 text-center"> + <h1 className="text-2xl leading-none font-semibold tracking-tight"> + {tGlobal("register")} + </h1> + <CardDescription>{t("desc")}</CardDescription> + </div> + + {form} + + {sso} + </div> + + <div className="text-accent-foreground p-6 text-center text-sm"> + {t.rich("already_have_account", { + link: text => ( + <LinkComponent + className="text-primary font-semibold" + href={signInHref} + > + {text} + </LinkComponent> + ), + })} + </div> + </Card> + </WrapperSignUp> + </div> + ); +}; 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 <FormSignUp captcha={captcha} isEmail={isEmail} />; }; -const SignUpFormSkeleton = () => ( - <div className="space-y-8"> - {[0, 1, 2].map(field => ( - <div className="space-y-2" key={field}> - <Skeleton className="h-4 w-24" /> - <Skeleton className="h-9 w-full" /> - </div> - ))} - - <div className="flex gap-2"> - <Skeleton className="size-4 shrink-0" /> - <Skeleton className="h-4 w-48" /> - </div> - - <Skeleton className="h-9 w-full" /> - </div> +/** + * 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 `<Suspense>` + * 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 = () => ( + <I18nProvider namespaces={["core.auth.sign_up", "core.auth.sso"]}> + <SignUpCard + form={ + <React.Suspense fallback={<SignUpFormSkeleton />}> + <SignUpForm /> + </React.Suspense> + } + sso={ + <React.Suspense fallback={<SSOButtonsSkeleton />}> + <SSOButtons /> + </React.Suspense> + } + /> + </I18nProvider> ); - -export const SignUpView = async () => { - const [t, tGlobal] = await Promise.all([ - getTranslations("core.auth.sign_up"), - getTranslations("core.global"), - ]); - - return ( - <I18nProvider namespaces={["core.auth.sign_up", "core.auth.sso"]}> - <div className="mx-auto flex max-w-md flex-col justify-center px-4 py-16 md:min-h-[calc(100vh-4rem)]"> - <WrapperSignUp> - <Card className="bg-muted gap-0 p-0"> - <div className="bg-card rounded-xl p-6"> - <div className="mb-10 space-y-2 text-center"> - <h1 className="text-2xl leading-none font-semibold tracking-tight"> - {tGlobal("register")} - </h1> - <CardDescription>{t("desc")}</CardDescription> - </div> - - <React.Suspense fallback={<SignUpFormSkeleton />}> - <SignUpForm /> - </React.Suspense> - - <React.Suspense fallback={<SSOButtonsSkeleton />}> - <SSOButtons /> - </React.Suspense> - </div> - - <div className="text-accent-foreground p-6 text-center text-sm"> - {t.rich("already_have_account", { - link: text => ( - <Link className="text-primary font-semibold" href="/login"> - {text} - </Link> - ), - })} - </div> - </Card> - </WrapperSignUp> - </div> - </I18nProvider> - ); -}; 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<string, string>; + 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 ( + <div className="container mx-auto p-4"> + <BreadcrumbRenderContent crumbs={crumbs} LinkComponent={LinkComponent} /> + </div> + ); +}; 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<string, string>; - 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 ( - <div className="container mx-auto p-4"> - <BreadcrumbRender crumbs={crumbs} /> - </div> - ); -}; +/** {@link BreadcrumbMainContent}, wired to `next-intl`'s locale-aware `Link`. */ +export const BreadcrumbMain = (props: BreadcrumbMainProps) => ( + <BreadcrumbMainContent {...props} LinkComponent={Link} /> +); 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 ( + <Breadcrumb + className={cn( + scrollable && + "no-scrollbar scroll-fade-x overflow-x-auto overscroll-x-contain", + )} + > + <BreadcrumbList + className={cn(scrollable && "flex-nowrap whitespace-nowrap")} + > + {crumbs.map((crumb, index) => ( + <Fragment key={crumb.href}> + {index > 0 && <BreadcrumbSeparator />} + <BreadcrumbItem> + {crumb.isCurrent ? ( + <BreadcrumbPage>{crumb.label}</BreadcrumbPage> + ) : crumb.isLink ? ( + <BreadcrumbLink + render={ + <LinkComponent href={crumb.href}> + {crumb.label} + </LinkComponent> + } + /> + ) : ( + <span>{crumb.label}</span> + )} + </BreadcrumbItem> + </Fragment> + ))} + </BreadcrumbList> + </Breadcrumb> + ); +}; 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 ( - <Breadcrumb - className={cn( - scrollable && - "no-scrollbar scroll-fade-x overflow-x-auto overscroll-x-contain", - )} - > - <BreadcrumbList - className={cn(scrollable && "flex-nowrap whitespace-nowrap")} - > - {crumbs.map((crumb, index) => ( - <Fragment key={crumb.href}> - {index > 0 && <BreadcrumbSeparator />} - <BreadcrumbItem> - {crumb.isCurrent ? ( - <BreadcrumbPage>{crumb.label}</BreadcrumbPage> - ) : crumb.isLink ? ( - <BreadcrumbLink - render={<Link href={crumb.href}>{crumb.label}</Link>} - /> - ) : ( - <span>{crumb.label}</span> - )} - </BreadcrumbItem> - </Fragment> - ))} - </BreadcrumbList> - </Breadcrumb> - ); -}; +}) => ( + <BreadcrumbRenderContent + crumbs={crumbs} + LinkComponent={Link} + scrollable={scrollable} + /> +); export const BreadcrumbSkeleton = ({ crumbs = 2 }: { crumbs?: number }) => ( <Breadcrumb> 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", From 0cfc7ee4c2da347019539e483c03a832bb1cde1b Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Fri, 28 Aug 2026 18:16:59 +0200 Subject: [PATCH 2/3] fix: Middleware routing --- apps/web/src/lib/auth/password-reset-route.ts | 70 ++++++++++++---- apps/web/src/lib/auth/shared.ts | 27 ++++++- apps/web/src/lib/devices/devices.ts | 52 ++++++++---- apps/web/src/lib/files/my-files.ts | 68 ++++++++++++---- apps/web/src/lib/middleware-config.ts | 79 +++++++++++++++---- .../src/routes/_main/_authenticated/files.tsx | 50 ++++++++++-- .../_main/_authenticated/settings/devices.tsx | 35 ++++++-- apps/web/src/routes/login.tsx | 8 ++ apps/web/src/routes/login_.reset-password.tsx | 34 +++++--- apps/web/src/routes/register.tsx | 20 +++++ .../src/server/middleware-config.server.ts | 20 +++-- apps/web/src/tests/auth-routes.test.ts | 69 +++++++++++++++- apps/web/src/tests/devices-route.test.ts | 76 +++++++++++++----- apps/web/src/tests/my-files-route.test.ts | 72 +++++++++++++---- .../settings/devices/devices-query.test.ts | 64 +++++++++++++-- .../auth/settings/devices/devices-query.ts | 73 ++++++++++++----- .../src/views/files/my-files-query.test.ts | 75 +++++++++++++++--- .../vitnode/src/views/files/my-files-query.ts | 72 +++++++++++++---- 18 files changed, 783 insertions(+), 181 deletions(-) diff --git a/apps/web/src/lib/auth/password-reset-route.ts b/apps/web/src/lib/auth/password-reset-route.ts index 3bc7834db..ee73c92e3 100644 --- a/apps/web/src/lib/auth/password-reset-route.ts +++ b/apps/web/src/lib/auth/password-reset-route.ts @@ -143,24 +143,64 @@ export const passwordResetNamespaces = ( : PASSWORD_RESET_BASE_NAMESPACES /** - * Whether this deployment has password recovery at all. + * Whether this deployment has password recovery - and the third answer, which is + * the point of this function existing. * * The API sends the reset link through the configured email adapter, so with no - * adapter there is no flow - and the Next.js view answers `notFound()` rather - * than rendering a form whose submit could never arrive. Preserved here as a - * named predicate over the deployment configuration, so the route reads as the - * rule rather than as a negated flag. - * - * Note what `isEmail: false` covers: it is also what - * `ANONYMOUS_MIDDLEWARE_CONFIG` says when the configuration could not be read at - * all. On this route that means an API outage renders the 404 rather than an - * error screen - a degradation, but the safe direction, and one that follows - * from Stage 6's decision that a failed configuration read must not blank the - * auth pages. Changing it would mean teaching that read to distinguish "no - * adapter" from "we could not ask". + * adapter there is no flow, and the Next.js view answers `notFound()` rather + * than rendering a form whose submit could never arrive. That much is preserved + * exactly. + * + * What is *not* preserved is the collapse underneath it. `isEmail: false` is two + * different facts wearing one value: + * + * the API answered, and no email adapter is configured -> disabled + * the API could not be reached, so this is the fallback -> unknown + * + * and the fallback says `isEmail: false` because that is the safe guess for the + * login form. Read as a boolean, an API outage therefore made this route answer + * **404** - the app claiming password recovery does not exist because it could + * not reach its own configuration. A visitor holding a valid recovery link, on a + * deployment that does send email, was told the page was not there. + * + * So the answer is three-valued, and the route acts on each differently: + * `notFound()` only for `disabled`, an error for `unknown`. `isKnown` is the + * whole of the distinction - see `MiddlewareConfigState`. */ -export const hasPasswordRecovery = ({ +export type PasswordRecoveryAvailability = 'available' | 'disabled' | 'unknown' + +export const passwordRecoveryAvailability = ({ isEmail, + isKnown, }: { isEmail: boolean -}): boolean => isEmail + isKnown: boolean +}): PasswordRecoveryAvailability => { + if (!isKnown) return 'unknown' + + return isEmail ? 'available' : 'disabled' +} + +/** + * The deployment configuration could not be read, so whether recovery exists is + * not known. + * + * Thrown out of the route's `beforeLoad`, where it takes TanStack Router's + * ordinary error path - the same one any failing loader takes - rather than the + * not-found path. That is the entire behavioural difference and it is the + * correct one: a `404` is a statement about this application's routes, and this + * is a statement about its API being unreachable. + * + * A named class rather than a bare `Error` so the route's intent is legible at + * the throw site and this suite can assert on it without matching English. + * Deliberately not a new error *screen*: the router already renders one, and + * inventing a degraded-configuration UX is not this stage's work. + */ +export class PasswordRecoveryUnknownError extends Error { + constructor() { + super( + 'The deployment configuration could not be read, so whether password recovery is available is unknown.', + ) + this.name = 'PasswordRecoveryUnknownError' + } +} diff --git a/apps/web/src/lib/auth/shared.ts b/apps/web/src/lib/auth/shared.ts index 2a2dc2bb4..f8a2a300b 100644 --- a/apps/web/src/lib/auth/shared.ts +++ b/apps/web/src/lib/auth/shared.ts @@ -106,10 +106,29 @@ export const SESSION_QUERY_KEY = ['vitnode', 'session'] as const * this function cannot create a session, end one, or redirect, because it cannot * do anything at all. * - * `user === null` is the only test for "signed out", because that is the only - * thing the API promises: no cookie, an expired session and a rate-limited - * response all arrive as `{ user: null }` - see `lib/session.ts`, which - * normalises the non-200 case so callers never have to narrow. + * `user === null` is the only test for "signed out", and it means exactly one + * thing: **the API answered, and nobody is signed in.** No cookie and an expired + * session both arrive that way, because both are a successful read of "there is + * no session here". + * + * ## A failed read never reaches this function + * + * It used to. `lib/session.ts` once returned `{ ai: { models: [] }, user: null }` + * for every non-200, so a `429` from the rate limiter, a `500` or an unreachable + * API arrived here indistinguishable from a guest - and `canAccessAuthenticatedRoute` + * dutifully signed a signed-in visitor out of a page they were entitled to. + * + * Stage 6 removed that normalisation deliberately. `getSession` now *rejects* + * when the session could not be read, which propagates through + * `ensureAuthState` and out of the guard's `beforeLoad` as an ordinary route + * error - so the visitor stays where they are and sees a failure, rather than + * being told they are anonymous. There is no third {@link AuthState} for "we + * could not find out", and there must not be one: the two states below are both + * answers, and an outage is not an answer. + * + * So this function is total over what it can actually receive - every + * `SessionApi` value is a session the API returned - and a caller must never + * read a rejection as a guest. */ export const authStateFromSession = (session: SessionApi): AuthState => { const { user } = session diff --git a/apps/web/src/lib/devices/devices.ts b/apps/web/src/lib/devices/devices.ts index 4cb5b989b..9651a9809 100644 --- a/apps/web/src/lib/devices/devices.ts +++ b/apps/web/src/lib/devices/devices.ts @@ -9,7 +9,7 @@ import type { import { useQueryClient } from '@tanstack/react-query' import { createIsomorphicFn } from '@tanstack/react-start' import { - DEVICES_QUERY_KEY, + devicesQueryKey, devicesQueryOptions, fetchDevicesInBrowser, } from '@vitnode/core/views/auth/settings/devices/devices-query' @@ -34,8 +34,8 @@ import { fetchDevicesOnServer } from '#/server/devices.server' * query cache instead of `revalidatePath`. * * The same shape as `#/lib/files/my-files`, deliberately - see the long note - * there. What is different is only that this list has no parameters, so there is - * one cache entry rather than a family. + * there. What is different is only that this list has no parameters, so one + * visitor has one cache entry rather than a family of pages and sorts. */ /** @@ -70,24 +70,37 @@ const fetchDevices: DevicesFetcher = createIsomorphicFn() /** * The devices list, as the one query definition every caller shares. * - * loader: context.queryClient.ensureQueryData(devicesQuery()) - * component: useSuspenseQuery(devicesQuery()) - * after a revoke: invalidate, and the component above refetches + * loader: context.queryClient.ensureQueryData(devicesQuery(userId)) + * component: useSuspenseQuery(devicesQuery(userId)) + * after a revoke: invalidate that visitor's entry, and it refetches + * + * `userId` is the *cache* owner and nothing more. It comes from + * `context.auth.user.id` - the `_authenticated` boundary's own state, read from + * the one canonical session query rather than from a second source - and it + * never reaches the API: `GET /users/devices` takes no arguments at all and + * derives the owner from the session cookie. See `devicesQueryKey` in core for + * why the entry has to be partitioned, and what went wrong when it was not. * * No `initialData`: the loader has already put the list in the entry this key * names and the SSR pass dehydrates it, so passing it again would be a second * copy of the same bytes that can disagree with the first. */ -export const devicesQuery = () => devicesQueryOptions({ fetchDevices }) +export const devicesQuery = (userId: number) => + devicesQueryOptions({ fetchDevices, userId }) /** - * Marks the cached devices list stale. + * Marks one visitor's cached devices list stale. * * One entry, named exactly - not `queryClient.invalidateQueries()` with no key. * The session, the messages and every other list this app holds are unaffected by * a device being signed out, and refetching them because of it is the blunt * version of the `revalidatePath('/[locale]/(main)', 'layout')` this replaces. * + * Scoped to `userId` for the same reason the key is. A long-lived browser client + * can still hold a previous visitor's partition; it is unreachable - every + * authenticated route builds its key from the current session - and refetching it + * would be a request on behalf of somebody who has signed out. + * * The session entry in particular is deliberately left alone, and that is a * finding rather than an omission: the API refuses to revoke the current device * with a `400`, so no revoke this app can perform ends the session it is @@ -102,8 +115,9 @@ export const devicesQuery = () => devicesQueryOptions({ fetchDevices }) */ export const invalidateDevices = async ( queryClient: QueryClient, + userId: number, ): Promise<void> => - await queryClient.invalidateQueries({ queryKey: DEVICES_QUERY_KEY }) + await queryClient.invalidateQueries({ queryKey: devicesQueryKey(userId) }) /** * Signs one device out, then refreshes the list if the list is now wrong. @@ -117,28 +131,38 @@ export const invalidateDevices = async ( */ export const revokeDevice = async ( queryClient: QueryClient, + userId: number, args: RevokeDeviceArgs, ): Promise<RevokeDeviceResult> => { const result = await revokeDeviceInBrowser(args) - if (shouldRefreshAfterRevoke(result)) await invalidateDevices(queryClient) + if (shouldRefreshAfterRevoke(result)) { + await invalidateDevices(queryClient, userId) + } return result } /** - * The one callback `DevicesContent` takes, bound to this router's cache. + * The one callback `DevicesContent` takes, bound to this router's cache and to + * the visitor whose partition of it the revoke may refresh. + * + * `userId` is taken as an argument rather than read here: the route reads + * `context.auth.user.id` once in its loader and hands the same value to the + * query options and to this hook, so the entry the loader filled is the entry a + * revoke marks stale. It scopes an invalidation and nothing else - the revoke + * request carries a device's `publicId` and no owner. * * Memoised, which is the only reason this is a hook rather than a call at the * point of use: it is a prop on a list that re-renders on every navigation, and a * new function identity would remount the confirm dialog mid-revoke. */ -export const useRevokeDeviceCallback = (): RevokeDevice => { +export const useRevokeDeviceCallback = (userId: number): RevokeDevice => { const queryClient = useQueryClient() return React.useMemo( () => async (args: RevokeDeviceArgs) => - await revokeDevice(queryClient, args), - [queryClient], + await revokeDevice(queryClient, userId, args), + [queryClient, userId], ) } diff --git a/apps/web/src/lib/files/my-files.ts b/apps/web/src/lib/files/my-files.ts index 3be846ea3..337154de3 100644 --- a/apps/web/src/lib/files/my-files.ts +++ b/apps/web/src/lib/files/my-files.ts @@ -21,8 +21,8 @@ import { } from '@vitnode/core/views/files/my-files-delete' import { fetchMyFilesPageInBrowser, - MY_FILES_QUERY_ROOT, myFilesQueryOptions, + myFilesQueryRoot, } from '@vitnode/core/views/files/my-files-query' import React from 'react' @@ -71,9 +71,9 @@ const fetchMyFilesPage: MyFilesPageFetcher = createIsomorphicFn() /** * The files table, as the one query definition every caller shares. * - * loader: context.queryClient.ensureQueryData(myFilesQuery({ params })) - * component: useQuery(myFilesQuery({ params })) - * after a delete: invalidate, and the component above refetches + * loader: ensureQueryData(myFilesQuery({ params, userId })) + * component: useQuery(myFilesQuery({ params, userId })) + * after a delete: invalidate that visitor's family, and it refetches * * `params` must be the *normalised* ones - `normalizeMyFilesParams` from core, * over the route's validated search - because the cache key is built from them. @@ -81,15 +81,27 @@ const fetchMyFilesPage: MyFilesPageFetcher = createIsomorphicFn() * holding identical rows, and the loader would fill one while the component read * the other. * + * `userId` is the *cache* owner and nothing more. It comes from + * `context.auth.user.id` - the `_authenticated` boundary's own state, which is + * the canonical session read and not a second source - and it never reaches the + * API: `GET /users/files` takes no owner and derives one from the session + * cookie. See `myFilesQueryRoot` in core for why the entry has to be partitioned + * at all. + * * No `initialData`: the loader has already put the page in the entry this key * names and the SSR pass dehydrates it, so passing it again would be a second * copy of the same bytes that can disagree with the first. */ -export const myFilesQuery = ({ params }: { params: MyFilesParams }) => - myFilesQueryOptions({ fetchPage: fetchMyFilesPage, params }) +export const myFilesQuery = ({ + params, + userId, +}: { + params: MyFilesParams + userId: number +}) => myFilesQueryOptions({ fetchPage: fetchMyFilesPage, params, userId }) /** - * Marks every cached page of the visitor's files stale. + * Marks every cached page of *one* visitor's files stale. * * The whole family, by prefix - not the one page on screen. A delete changes * which rows exist, so every other page, sort and search of the same list is now @@ -99,14 +111,22 @@ export const myFilesQuery = ({ params }: { params: MyFilesParams }) => * changed, and refetching them because a file was deleted is the blunt version * of the `revalidatePath` this replaces. * + * Scoped to `userId`, which narrows it twice over. `myFilesQueryRoot(userId)` is + * a prefix of every one of that visitor's pages and of nobody else's, so a + * long-lived browser client that still holds a previous visitor's partition + * keeps it - untouched and unreachable, because every authenticated route builds + * its key from the current session. Invalidating another partition would refetch + * a list nobody is looking at, on behalf of a visitor who has gone. + * * Invalidating rather than removing keeps the current rows on screen while the * fresh ones are fetched, instead of blanking the table under the dialog that is * still open. */ export const invalidateMyFiles = async ( queryClient: QueryClient, + userId: number, ): Promise<void> => - await queryClient.invalidateQueries({ queryKey: MY_FILES_QUERY_ROOT }) + await queryClient.invalidateQueries({ queryKey: myFilesQueryRoot(userId) }) /** * Deletes one file, then refreshes the table if it actually went. @@ -117,11 +137,12 @@ export const invalidateMyFiles = async ( */ export const deleteMyFile = async ( queryClient: QueryClient, + userId: number, args: DeleteMyFileArgs, ): Promise<DeleteFileResult> => { const result = await deleteMyFileInBrowser(args) - if (!result.error) await invalidateMyFiles(queryClient) + if (!result.error) await invalidateMyFiles(queryClient, userId) return result } @@ -137,24 +158,41 @@ export const deleteMyFile = async ( */ export const deleteMyFiles = async ( queryClient: QueryClient, + userId: number, args: DeleteMyFilesArgs, ): Promise<BulkDeleteFilesResult> => { const result = await deleteMyFilesInBrowser(args) - if (shouldRefreshAfterBulkDelete(result)) await invalidateMyFiles(queryClient) + if (shouldRefreshAfterBulkDelete(result)) { + await invalidateMyFiles(queryClient, userId) + } return result } /** - * The two callbacks `MyFilesTableContent` takes, bound to this router's cache. + * The two callbacks `MyFilesTableContent` takes, bound to this router's cache + * and to the visitor whose partition of it they may touch. + * + * `userId` is taken as an argument rather than read here, and that is the whole + * of "do not derive it three different ways": the route reads + * `context.auth.user.id` once in its loader, returns it, and hands the same + * value to the query options and to this hook. A second read - even of the same + * canonical entry - could resolve differently mid-navigation and invalidate a + * partition the table is not showing. + * + * It scopes an invalidation and nothing else. Neither delete request carries an + * owner; `DELETE /users/files/{id}` authorizes from the session cookie, as it + * did before this parameter existed. * * Memoised on the client, which is the only reason this is a hook rather than * two calls at the point of use: the callbacks are props on a table that * re-renders on every navigation, and new function identities would remount the * confirm dialogs mid-delete. */ -export const useMyFilesDeleteCallbacks = (): { +export const useMyFilesDeleteCallbacks = ( + userId: number, +): { onDeleteFile: DeleteMyFile onDeleteFiles: DeleteMyFiles } => { @@ -163,10 +201,10 @@ export const useMyFilesDeleteCallbacks = (): { return React.useMemo( () => ({ onDeleteFile: async (args: DeleteMyFileArgs) => - await deleteMyFile(queryClient, args), + await deleteMyFile(queryClient, userId, args), onDeleteFiles: async (args: DeleteMyFilesArgs) => - await deleteMyFiles(queryClient, args), + await deleteMyFiles(queryClient, userId, args), }), - [queryClient], + [queryClient, userId], ) } diff --git a/apps/web/src/lib/middleware-config.ts b/apps/web/src/lib/middleware-config.ts index 148ebf27d..61f762eea 100644 --- a/apps/web/src/lib/middleware-config.ts +++ b/apps/web/src/lib/middleware-config.ts @@ -22,12 +22,49 @@ import { fetchMiddlewareConfigOnServer } from '#/server/middleware-config.server export type MiddlewareConfig = z.infer<typeof routeMiddlewareSchema> /** - * What the login page renders when the configuration cannot be read: the email - * and password fields, and nothing that depends on a configured adapter. + * The configuration, plus the one thing the configuration itself cannot say: + * whether it was actually read. + * + * A single flag rather than a wrapper, and that is a decision about the four + * consumers. `/login`, `/register` and the SSO callback all want the *fields* - + * `config.captcha`, `config.isEmail`, `ssoProvidersOf(config)` - and are + * indifferent to where they came from; only password recovery has to tell the + * two apart. Wrapping the config in a `{ status, config }` result would make all + * four unwrap it to serve one, so the flag rides alongside the fields and the + * screens that do not care never mention it. + * + * ## Why the distinction has to exist at all + * + * A failed read degrades to {@link UNKNOWN_MIDDLEWARE_CONFIG}, which says + * `isEmail: false` - and for `/login` that is exactly right, a reduced form + * rather than no form. But `isEmail: false` is also what a deployment with no + * email adapter genuinely looks like, and `/login/reset-password` answers that + * with `notFound()`. Collapsed together, an API outage made password recovery + * return **404**: the app claiming a route does not exist because it could not + * reach its own API. See `passwordRecoveryAvailability`. + */ +export interface MiddlewareConfigState extends MiddlewareConfig { + /** + * `true` when these fields are the API's answer, `false` when they are the + * fallback below. + * + * Not part of the API schema - it is this app's record of whether the read + * succeeded, so a consumer can distinguish "configured off" from "unknown". + */ + isKnown: boolean +} + +/** + * What a screen renders when the configuration cannot be read: the email and + * password fields, and nothing that depends on a configured adapter. * * Shared by both transports so a failure looks the same during SSR and after * hydration, rather than the page changing shape when it rehydrates. * + * `isKnown: false` is the load-bearing field. Every other value here is a + * *guess* chosen to degrade safely, and a consumer that must not guess reads + * this one first. + * * ## What it costs the screens that need a captcha * * `captcha` is absent here, and it cannot be otherwise - a widget needs a site @@ -39,11 +76,17 @@ export type MiddlewareConfig = z.infer<typeof routeMiddlewareSchema> * and nothing is claimed to have been. The alternative would be inventing a * configuration, which is how a form ends up looking solved when it is not. */ -export const ANONYMOUS_MIDDLEWARE_CONFIG: MiddlewareConfig = Object.freeze({ +export const UNKNOWN_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({ isEmail: false, + isKnown: false, sso: [], }) +/** The API's answer, marked as one. */ +export const knownMiddlewareConfig = ( + config: MiddlewareConfig, +): MiddlewareConfigState => ({ ...config, isKnown: true }) + const middleware = clientModule<typeof middlewareModule>('@vitnode/core') /** @@ -52,21 +95,22 @@ const middleware = clientModule<typeof middlewareModule>('@vitnode/core') * Core's own browser fetcher, which is what the Next.js app's client components * use - so a hydrated page and a Next.js page make the identical request. */ -const fetchMiddlewareConfigInBrowser = async (): Promise<MiddlewareConfig> => { - try { - const response = await fetcherClient(middleware, { - method: 'get', - module: 'middleware', - path: '/', - }) +const fetchMiddlewareConfigInBrowser = + async (): Promise<MiddlewareConfigState> => { + try { + const response = await fetcherClient(middleware, { + method: 'get', + module: 'middleware', + path: '/', + }) - if (response.status !== 200) return ANONYMOUS_MIDDLEWARE_CONFIG + if (response.status !== 200) return UNKNOWN_MIDDLEWARE_CONFIG - return await response.json() - } catch { - return ANONYMOUS_MIDDLEWARE_CONFIG + return knownMiddlewareConfig(await response.json()) + } catch { + return UNKNOWN_MIDDLEWARE_CONFIG + } } -} /** * The transport boundary, and the reason one query definition works in a loader @@ -114,6 +158,11 @@ export const middlewareConfigQueryOptions = () => * Core's own normaliser - the one the Next.js provider row uses - so a provider * missing a name, or listed twice, produces the same button row in both * frameworks. + * + * Takes the bare {@link MiddlewareConfig}, so it reads a + * {@link MiddlewareConfigState} without caring which one it is: an unread + * configuration has no providers, and an empty provider row is the correct + * degraded rendering. Nothing here needs to know the difference. */ export const ssoProvidersOf = (config: MiddlewareConfig): SSOProvider[] => normalizeSSOProviders(config.sso) diff --git a/apps/web/src/routes/_main/_authenticated/files.tsx b/apps/web/src/routes/_main/_authenticated/files.tsx index 357e3e4d8..a2dc9bd6f 100644 --- a/apps/web/src/routes/_main/_authenticated/files.tsx +++ b/apps/web/src/routes/_main/_authenticated/files.tsx @@ -52,9 +52,9 @@ import { vitNodeShellConfig } from '#/vitnode.shell.config' * The table is `myFilesQuery` and nothing else, in the loader and in the * component: * - * loader: ensureQueryData(myFilesQuery({ params })) - * component: useSuspenseQuery(myFilesQuery({ params })) - * after a delete: invalidate the family, and the component refetches + * loader: ensureQueryData(myFilesQuery({ params, userId })) + * component: useSuspenseQuery(myFilesQuery({ params, userId })) + * after a delete: invalidate that visitor's family, and it refetches * * Same key, same request, same refusal handling - so the page the server rendered * is the page the browser reads, and there is no `initialData` anywhere: the @@ -65,6 +65,27 @@ import { vitNodeShellConfig } from '#/vitnode.shell.config' * `params` is the *normalised* request from `loaderDeps`, handed to the component * through the loader rather than derived a second time, so the two cannot drift * apart through a difference in how each computed it. + * + * ## Whose files, in the cache as well as at the API + * + * `userId` is handed through the same way and for a sharper reason. The browser's + * `QueryClient` is created once per document and survives a sign-out, so a key of + * `["files", "me", params]` is only unique for as long as "me" is: after A signs + * out and B signs in, B's loader would `ensureQueryData` an entry A had already + * filled, find it populated, make no request, and render A's file names. Hono + * cannot refuse a request nobody sent. + * + * So the key carries the owner - `["files", "user", <id>, params]` - and the id + * comes from `context.auth.user.id`, which is `_authenticated`'s own state from + * the one canonical session query. It is read **once**, in the loader, and + * returned; the component and the delete callbacks take it from `loaderData` + * rather than reading it again, so there is exactly one answer per render pass + * and no way for the loader to fill one partition while the component reads + * another. + * + * It addresses a cache entry and nothing else. `GET /users/files` takes no owner + * and derives one from the session cookie on every request, exactly as before - + * see `myFilesQueryRoot` in `@vitnode/core`. */ /** @@ -118,6 +139,14 @@ export const Route = createFileRoute('/_main/_authenticated/files')({ * uploaded, which is the one thing this must never look like. */ loader: async ({ context, deps }) => { + /* + The visitor, from the guard that let this route load. `context.auth` is + `_authenticated`'s `beforeLoad` return, already narrowed to the signed-in + half of the union - so `auth.user` needs no check, and this cannot disagree + with the rule that admitted the navigation. + */ + const userId = context.auth.user.id + const [intl] = await Promise.all([ context.queryClient.ensureQueryData( intlQueryOptions({ @@ -126,7 +155,7 @@ export const Route = createFileRoute('/_main/_authenticated/files')({ }), ), context.queryClient.ensureQueryData( - myFilesQuery({ params: deps.params }), + myFilesQuery({ params: deps.params, userId }), ), ]) @@ -150,7 +179,12 @@ export const Route = createFileRoute('/_main/_authenticated/files')({ namespace: 'core.files', }) - return { description: t('desc'), params: deps.params, title: t('title') } + return { + description: t('desc'), + params: deps.params, + title: t('title'), + userId, + } }, /** * The page's metadata, in the language the request resolved to. @@ -190,11 +224,11 @@ export const Route = createFileRoute('/_main/_authenticated/files')({ }) function MyFilesRoute() { - const { description, params, title } = Route.useLoaderData() + const { description, params, title, userId } = Route.useLoaderData() const search = Route.useSearch() const navigate = Route.useNavigate() - const { data } = useSuspenseQuery(myFilesQuery({ params })) - const { onDeleteFile, onDeleteFiles } = useMyFilesDeleteCallbacks() + const { data } = useSuspenseQuery(myFilesQuery({ params, userId })) + const { onDeleteFile, onDeleteFiles } = useMyFilesDeleteCallbacks(userId) /** * The one thing the shared table cannot decide for itself: how to change a URL. diff --git a/apps/web/src/routes/_main/_authenticated/settings/devices.tsx b/apps/web/src/routes/_main/_authenticated/settings/devices.tsx index 2861b216e..2baab87f0 100644 --- a/apps/web/src/routes/_main/_authenticated/settings/devices.tsx +++ b/apps/web/src/routes/_main/_authenticated/settings/devices.tsx @@ -30,8 +30,8 @@ import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel' * * ## One query contract, one cache entry * - * loader: ensureQueryData(devicesQuery()) - * component: useSuspenseQuery(devicesQuery()) + * 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 @@ -39,6 +39,21 @@ import { loadSettingsPanel, settingsPanelHead } from '#/lib/settings/panel' * 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", <id>]` - 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 @@ -65,12 +80,19 @@ export const Route = createFileRoute('/_main/_authenticated/settings/devices')({ * 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()), + context.queryClient.ensureQueryData(devicesQuery(userId)), ]) - return panel + return { ...panel, userId } }, /** * The tab title and nothing else - `robots` is the layout's, declared once for @@ -134,8 +156,9 @@ function DevicesPending() { } function DevicesRoute() { - const { data } = useSuspenseQuery(devicesQuery()) - const onRevoke = useRevokeDeviceCallback() + const { userId } = Route.useLoaderData() + const { data } = useSuspenseQuery(devicesQuery(userId)) + const onRevoke = useRevokeDeviceCallback(userId) return ( <> diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index e1e9579ed..de8c92c3e 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -218,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 index d4760fd16..c8ef55e14 100644 --- a/apps/web/src/routes/login_.reset-password.tsx +++ b/apps/web/src/routes/login_.reset-password.tsx @@ -16,8 +16,9 @@ import { requestPasswordResetAction, } from '#/lib/auth/actions' import { - hasPasswordRecovery, normalizePasswordResetSearch, + passwordRecoveryAvailability, + PasswordRecoveryUnknownError, passwordResetMode, passwordResetNamespaces, } from '#/lib/auth/password-reset-route' @@ -109,14 +110,24 @@ const translateTitle = (locale: string, messages: AbstractIntlMessages) => export const Route = createFileRoute('/login_/reset-password')({ validateSearch: normalizePasswordResetSearch, /** - * Password recovery only exists on a deployment that can send email. + * 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 + * 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 @@ -124,21 +135,24 @@ export const Route = createFileRoute('/login_/reset-password')({ * 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 not-found boundary before the stream - * opens and answers **404** (`applyFailure` in `@tanstack/router-core`), so the - * status is right without this route setting one by hand. - * - * `hasPasswordRecovery` also reads `false` when the configuration could not be - * read at all - see its own note, which owns that trade-off. + * 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 (!hasPasswordRecovery(config)) throw notFound() + if (availability === 'disabled') throw notFound() }, /** * The loader re-runs when the *mode* changes, and only then. diff --git a/apps/web/src/routes/register.tsx b/apps/web/src/routes/register.tsx index 41de90fc7..447751952 100644 --- a/apps/web/src/routes/register.tsx +++ b/apps/web/src/routes/register.tsx @@ -176,6 +176,26 @@ export const Route = createFileRoute('/register')({ }) 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()) /** 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<typeof middlewareModule>('@vitnode/core') export const fetchMiddlewareConfigOnServer = - async (): Promise<MiddlewareConfig> => { + async (): Promise<MiddlewareConfigState> => { 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 index ea092fe23..aa3b3bddf 100644 --- a/apps/web/src/tests/auth-routes.test.ts +++ b/apps/web/src/tests/auth-routes.test.ts @@ -5,12 +5,16 @@ import { import { describe, expect, it } from 'vitest' import { - hasPasswordRecovery, 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' /** @@ -179,10 +183,67 @@ describe('the namespaces each recovery screen needs', () => { }) }) +/** + * 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', () => { - expect(hasPasswordRecovery({ isEmail: true })).toBe(true) - expect(hasPasswordRecovery({ isEmail: false })).toBe(false) + 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() }) }) diff --git a/apps/web/src/tests/devices-route.test.ts b/apps/web/src/tests/devices-route.test.ts index 2f14150dc..9fd464e3a 100644 --- a/apps/web/src/tests/devices-route.test.ts +++ b/apps/web/src/tests/devices-route.test.ts @@ -2,7 +2,7 @@ import type * as DevicesRevokeModule from '@vitnode/core/views/auth/settings/dev import type { RevokeDeviceResult } from '@vitnode/core/views/auth/settings/devices/devices-revoke' import { hashKey, QueryClient } from '@tanstack/react-query' -import { DEVICES_QUERY_KEY } from '@vitnode/core/views/auth/settings/devices/devices-query' +import { devicesQueryKey } from '@vitnode/core/views/auth/settings/devices/devices-query' import { beforeEach, describe, expect, it, vi } from 'vitest' /** @@ -37,6 +37,12 @@ vi.mock( 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 @@ -44,8 +50,10 @@ const MESSAGES_KEY = ['intl', 'en', 'core.global'] as const const seed = () => { const queryClient = new QueryClient() - queryClient.setQueryData(devicesQuery().queryKey, { devices: [] }) - queryClient.setQueryData(SESSION_KEY, { user: { id: 1 } }) + 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 @@ -62,17 +70,35 @@ 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().queryKey)).toBe(hashKey(DEVICES_QUERY_KEY)) + 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().queryKey).toEqual(['devices', 'me']) + expect(devicesQuery(USER).queryKey).toEqual(['devices', 'user', USER]) }) it('asks once, so a 429 is not answered by two more requests', () => { - expect(devicesQuery().retry).toBe(false) + 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), + ) }) }) @@ -80,9 +106,9 @@ 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) + await invalidateDevices(queryClient, USER) - expect(isStale(queryClient, DEVICES_QUERY_KEY)).toBe(true) + expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true) }) it('leaves everything else in the cache alone', async () => { @@ -92,7 +118,7 @@ describe('a revoke makes the devices list stale, and only that', () => { // of the `revalidatePath` this replaces. const queryClient = seed() - await invalidateDevices(queryClient) + await invalidateDevices(queryClient, USER) expect(isStale(queryClient, SESSION_KEY)).toBe(false) expect(isStale(queryClient, MESSAGES_KEY)).toBe(false) @@ -103,9 +129,19 @@ describe('a revoke makes the devices list stale, and only that', () => { // dialog that is still closing. const queryClient = seed() - await invalidateDevices(queryClient) + 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(queryClient.getQueryData(DEVICES_QUERY_KEY)).toBeDefined() + expect(isStale(queryClient, devicesQueryKey(OTHER_USER))).toBe(false) }) it('does not invalidate the session, because the current device cannot be revoked', async () => { @@ -115,7 +151,7 @@ describe('a revoke makes the devices list stale, and only that', () => { // authenticated - which is why this invalidation is one key rather than two. const queryClient = seed() - await revokeDevice(queryClient, { publicId: 'a1b2c3' }) + await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' }) expect(isStale(queryClient, SESSION_KEY)).toBe(false) }) @@ -126,9 +162,9 @@ describe('the revoke refreshes on exactly the statuses that changed something', const queryClient = seed() nextRevokeResult = { data: true } - await revokeDevice(queryClient, { publicId: 'a1b2c3' }) + await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' }) - expect(isStale(queryClient, DEVICES_QUERY_KEY)).toBe(true) + expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true) }) it.each([404, 400])( @@ -137,9 +173,9 @@ describe('the revoke refreshes on exactly the statuses that changed something', const queryClient = seed() nextRevokeResult = { error: { status } } - await revokeDevice(queryClient, { publicId: 'a1b2c3' }) + await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' }) - expect(isStale(queryClient, DEVICES_QUERY_KEY)).toBe(true) + expect(isStale(queryClient, devicesQueryKey(USER))).toBe(true) }, ) @@ -152,9 +188,9 @@ describe('the revoke refreshes on exactly the statuses that changed something', const queryClient = seed() nextRevokeResult = { error: { status } } - await revokeDevice(queryClient, { publicId: 'a1b2c3' }) + await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' }) - expect(isStale(queryClient, DEVICES_QUERY_KEY)).toBe(false) + expect(isStale(queryClient, devicesQueryKey(USER))).toBe(false) }, ) @@ -162,7 +198,9 @@ describe('the revoke refreshes on exactly the statuses that changed something', const queryClient = seed() nextRevokeResult = { error: { status: 429 } } - expect(await revokeDevice(queryClient, { publicId: 'a1b2c3' })).toEqual({ + expect( + await revokeDevice(queryClient, USER, { publicId: 'a1b2c3' }), + ).toEqual({ error: { status: 429 }, }) }) 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/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts index da30a30c1..25baf1826 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { DEVICE_TYPES, - DEVICES_QUERY_KEY, + devicesQueryKey, devicesQueryOptions, devicesRequest, DevicesRequestError, @@ -50,32 +50,82 @@ describe("the request the API is asked for", () => { }); }); -describe("one list, one cache entry", () => { - it("is keyed by nothing at all, because the request is", () => { - expect(DEVICES_QUERY_KEY).toEqual(["devices", "me"]); +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().queryKey)).toBe( + 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(DEVICES_QUERY_KEY[0]).not.toBe("vitnode"); + expect(devicesQueryKey(10)[0]).not.toBe("vitnode"); }); it("asks once, because every failure it can have is worse when repeated", () => { - expect(devicesQueryOptions().retry).toBe(false); + 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"]); }); }); diff --git a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts index 5c46525fd..79433cc87 100644 --- a/packages/vitnode/src/views/auth/settings/devices/devices-query.ts +++ b/packages/vitnode/src/views/auth/settings/devices/devices-query.ts @@ -91,8 +91,13 @@ export interface DevicesApi { * 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. That is also why the cache key below has nothing - * in it. + * 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 = () => ({ @@ -169,15 +174,26 @@ export const fetchDevicesInBrowser: DevicesFetcher = async () => { }; /** - * The cache entry this list reads and writes, and the root an invalidation - * names. + * 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 * - * One key with nothing in it, because the request has nothing in it: the route - * takes no parameters and answers with whichever user the cookie identifies. A - * user id here would be a second source of truth for something the cookie - * already decides, and the QueryClient it lives in is per request on the server - * and per browser on the client, so there is no client holding two visitors' - * lists. + * 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 @@ -185,25 +201,42 @@ export const fetchDevicesInBrowser: DevicesFetcher = async () => { * 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. * - * Exported as the invalidation target too - there is exactly one entry, so the - * key and the family are the same value. + * ## 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 DEVICES_QUERY_KEY = ["devices", "me"] as const; +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 })) + * context.queryClient.ensureQueryData( + * devicesQueryOptions({ fetchDevices, userId }), + * ) * * and the component reads the very same options back: * - * const { data } = useSuspenseQuery(devicesQuery()) + * 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 DEVICES_QUERY_KEY} refetches through the identical contract. + * {@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 @@ -226,12 +259,16 @@ export const DEVICES_QUERY_KEY = ["devices", "me"] as const; */ 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: DEVICES_QUERY_KEY, + queryKey: devicesQueryKey(userId), retry: false, }); 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<typeof normalizeMyFilesParams>[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, }); From 50f5bf134e2374b32a879218049eefb7dd37062f Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axendeveloper@gmail.com> Date: Sat, 29 Aug 2026 08:48:45 +0200 Subject: [PATCH 3/3] fix: Multilanguage pages tanstack start --- apps/web/src/components/route-messages.tsx | 20 +- apps/web/src/locales/@vitnode/core/pl.json | 260 ++++++++++++++- apps/web/src/routes/__root.tsx | 53 +-- apps/web/src/routes/_main/index.tsx | 39 ++- apps/web/src/tests/intl-input.test.ts | 6 +- apps/web/src/tests/intl-provider.test.ts | 57 +++- apps/web/src/tests/intl-query.test.ts | 26 ++ apps/web/src/tests/isolation.test.ts | 121 ++++++- apps/web/src/tests/locale-ssr.test.ts | 6 +- apps/web/src/tests/messages.test.ts | 11 +- apps/web/src/tests/route-namespaces.test.ts | 304 ++++++++++++++++++ .../confirm-action-alert-dialog.tsx | 2 +- .../src/components/confirm-action/content.tsx | 2 +- .../vitnode/src/components/form/auto-form.tsx | 2 +- .../src/components/form/common/label.tsx | 2 +- .../src/components/form/fields/multi-lang.tsx | 2 +- .../switchers/themes/theme-switcher.tsx | 2 +- .../vitnode/src/components/table/content.tsx | 13 +- .../vitnode/src/components/table/filters.tsx | 2 +- .../src/components/table/no-results.tsx | 46 +++ .../src/components/table/pagination.tsx | 2 +- .../vitnode/src/components/table/search.tsx | 2 +- .../src/components/table/selection.tsx | 2 +- .../src/components/ui/alert-dialog.tsx | 2 +- .../src/components/ui/button-client.tsx | 2 +- packages/vitnode/src/components/ui/dialog.tsx | 2 +- packages/vitnode/src/components/ui/form.tsx | 2 +- .../src/lib/i18n/rsc-boundaries.test.ts | 200 ++++++++++++ .../src/views/layouts/rate-limit-listener.tsx | 2 +- 29 files changed, 1093 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/tests/route-namespaces.test.ts create mode 100644 packages/vitnode/src/components/table/no-results.tsx create mode 100644 packages/vitnode/src/lib/i18n/rsc-boundaries.test.ts diff --git a/apps/web/src/components/route-messages.tsx b/apps/web/src/components/route-messages.tsx index 5c0999580..13ab08169 100644 --- a/apps/web/src/components/route-messages.tsx +++ b/apps/web/src/components/route-messages.tsx @@ -26,16 +26,22 @@ import { intlQueryOptions } from '#/lib/i18n/query' * ## Why two providers * * One component, two module records. `@vitnode/core` is external to Vite's SSR - * pass and therefore loaded by Node, while this app's source runs through - * Vite's module runner - so `use-intl` imported here and `use-intl` imported - * inside a core component can be two records with two React contexts. The - * outer one covers this app's own code; the inner comes from core itself + * pass and therefore loaded by Node, which resolves `use-intl` to its `default` + * (production) build, while this app's source runs through Vite's module + * runner, which resolves the same package to its `development` build - two + * files, two `createContext` calls, two React contexts. The outer one covers + * this app's own code; the inner comes from core itself * (`@vitnode/core/lib/i18n/provider`) and so is by construction the record - * every shared component reads. See the long note in `routes/__root.tsx`, which - * has the same shape for the same reason. + * every shared component reads - including the design-system components that + * used to reach it through `next-intl`, which now import `use-intl` directly. + * See the long note in `routes/__root.tsx`, which mounts the same pair for + * `core.global` for the same reason. * * Both get the same props from one object: two providers that disagreed would - * render half a page in the wrong language. + * render half a page in the wrong language. This is also why the pair belongs + * *here*, at the route boundary, rather than around each component that + * translates - a leaf that mounted its own would be the one place the route's + * namespaces could go missing. */ export const RouteMessages = ({ children, diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index f9023f682..6ec2453b6 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -1,17 +1,103 @@ { "core": { "global": { + "back_home": "Wróć na stronę główną", "cancel": "Anuluj", + "clear_filters": "Wyczyść filtry", "close": "Zamknij", + "confirm": "Potwierdź", + "confirm_action": { + "cancel": "Anuluj", + "confirm": "Tak, potwierdzam", + "desc": "Tej operacji nie można cofnąć.", + "title": "Czy na pewno?" + }, + "current_page": "Bieżąca strona", + "data_table": { + "clear_selection": "Wyczyść zaznaczenie", + "select_all": "Zaznacz wszystkie wiersze", + "select_row": "Zaznacz wiersz" + }, + "errors": { + "400": { + "desc": "Nie udało się przetworzyć żądania - jego parametry są nieprawidłowe.", + "title": "Nieprawidłowe żądanie" + }, + "403": { + "desc": "Nie masz uprawnień do tego zasobu.", + "title": "Brak dostępu" + }, + "404": { + "desc": "Ups! Strona, której szukasz, nie istnieje.", + "title": "Nie znaleziono strony" + }, + "409": { + "desc": "Nie udało się ukończyć żądania z powodu konfliktu z bieżącym stanem zasobu.", + "title": "Konflikt" + }, + "429": { + "desc": "Robisz to zbyt szybko. Zwolnij i spróbuj ponownie za chwilę.", + "retry": "Robisz to zbyt szybko. Spróbuj ponownie za {seconds, plural, one {# sekundę} few {# sekundy} many {# sekund} other {# sekundy}}.", + "title": "Zbyt wiele żądań" + }, + "500": { + "desc": "Przepraszamy, mamy chwilowe problemy techniczne po naszej stronie.", + "title": "Wewnętrzny błąd serwera" + }, + "captcha_internal_error": "Weryfikacja captcha nie powiodła się. Spróbuj ponownie później.", + "field_min_length": "To pole musi mieć co najmniej {min, plural, one {# znak} few {# znaki} many {# znaków} other {# znaku}}.", + "field_required": "To pole jest wymagane.", + "internal_server_error": "Wewnętrzny błąd serwera.", + "reference": "Numer błędu: {digest}", + "title": "Ups! Coś poszło nie tak.", + "try_again": "Spróbuj ponownie" + }, + "go_back": "Wróć", + "go_to_next_page": "Przejdź do następnej strony", + "go_to_page": "Przejdź do strony", + "go_to_prev_page": "Przejdź do poprzedniej strony", "language_switcher": "Zmień język", + "loading": "Ładowanie...", + "login": "Zaloguj się", + "next": "Dalej", + "next_page": "Następna strona", + "no_results": { + "desc": "Spróbuj zmienić wyszukiwanie lub filtry.", + "title": "Brak wyników" + }, + "optional": "Opcjonalne", + "or": "lub", + "previous": "Wstecz", + "previous_page": "Poprzednia strona", + "register": "Zarejestruj się", + "remove": "Usuń", + "results_not_found": "Brak wyników", "save": "Zapisz", - "theme_switcher": "Zmień motyw" + "search_placeholder": "Szukaj...", + "select_language": "Wybierz język", + "select_option": "Wybierz opcję", + "select_options": "Wybierz opcje", + "selected_count": "Zaznaczono: {count}", + "submit": "Wyślij", + "theme_switcher": "Zmień motyw", + "user_bar": { + "admin_cp": "Panel administratora", + "files": "Moje pliki", + "log_out": "Wyloguj się", + "mod_cp": "Panel moderatora", + "my_profile": "Mój profil", + "settings": "Ustawienia" + } }, "search": { "title": "Szukaj", "desc": "Przeszukaj wszystko w społeczności.", "discoverTitle": "Odkrywaj", "discoverDesc": "Zobacz najnowszą aktywność w społeczności.", + "nav": { + "discover": "Odkrywaj", + "search": "Szukaj" + }, "placeholder": "Szukaj…", "empty": "Nic tu jeszcze nie ma.", "loadMore": "Wczytaj więcej", @@ -26,6 +112,178 @@ "blog_post": "Wpis", "unknown": "Treść" } + }, + "auth": { + "sign_in": { + "desc": "Witaj ponownie! Zaloguj się na swoje konto.", + "do_not_have_account": "Nie masz konta? <link>Zarejestruj się</link>.", + "email": { + "invalid": "Nieprawidłowy adres e-mail.", + "label": "Adres e-mail" + }, + "errors": { + "access_denied": { + "desc": "Adres e-mail lub hasło są nieprawidłowe. Spróbuj ponownie i sprawdź, czy nie masz włączonego Caps Locka.", + "title": "Nieprawidłowe dane logowania" + } + }, + "password": { + "label": "Hasło", + "required": "Hasło jest wymagane.", + "reset": "Nie pamiętasz hasła?" + }, + "submit": "Zaloguj się" + }, + "sign_up": { + "already_have_account": "Masz już konto? <link>Zaloguj się</link>.", + "desc": "Cześć! Załóż konto, aby zacząć.", + "email": { + "exists": "Ten adres e-mail jest już zajęty.", + "invalid": "Nieprawidłowy adres e-mail.", + "label": "Adres e-mail" + }, + "email_confirmation": { + "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem.", + "desc": "Wysłaliśmy link potwierdzający na Twój adres e-mail", + "title": "Sprawdź swoją skrzynkę" + }, + "newsletter": { + "desc": "Otrzymuj najnowsze informacje i aktualizacje.", + "label": "Newsletter" + }, + "password": { + "invalid": "Hasło jest zbyt słabe.", + "label": "Hasło", + "requirements": { + "label": "Hasło powinno zawierać:", + "min_length": "Co najmniej 8 znaków", + "number": "Co najmniej jedną cyfrę", + "special_char": "Co najmniej jeden znak specjalny", + "uppercase": "Co najmniej jedną wielką literę" + } + }, + "submit": "Zarejestruj się", + "terms": { + "desc": "Akceptujesz nasze <link>dokumenty prawne i polityki</link>.", + "label": "Akceptuję regulamin", + "required": "Musisz zaakceptować regulamin." + }, + "username": { + "exists": "Ta nazwa użytkownika jest już zajęta.", + "label": "Nazwa użytkownika", + "max_length": "Nazwa użytkownika może mieć najwyżej 32 znaki.", + "min_length": "Nazwa użytkownika musi mieć co najmniej 3 znaki.", + "your_user_code": "Twój kod użytkownika: <code></code>" + } + }, + "sso": { + "access_denied": "Odmówiono dostępu do aplikacji lub żądanie wygasło. Spróbuj ponownie.", + "email_exists": { + "desc": "Konto z tym adresem e-mail już istnieje. Zaloguj się inną metodą i połącz konto <provider></provider> w ustawieniach profilu.", + "sign_in": "Przejdź do logowania", + "title": "Nie możesz zalogować się przez <provider></provider>" + }, + "or": "Lub kontynuuj przez" + }, + "reset_password": { + "confirmation": { + "check_spam": "Jeśli nie widzisz wiadomości w skrzynce odbiorczej, sprawdź folder ze spamem.", + "desc": "Wysłaliśmy link do zresetowania hasła na Twój adres e-mail:", + "title": "Sprawdź swoją skrzynkę" + }, + "desc": "Podaj swój adres e-mail, aby otrzymać link do zresetowania hasła.", + "submit": "Wyślij link resetujący", + "title": "Resetowanie hasła" + }, + "change_password": { + "desc": "Podaj poniżej swoje nowe hasło.", + "submit": "Zmień hasło", + "success": { + "desc": "Możesz teraz zalogować się nowym hasłem.", + "title": "Hasło zostało zmienione" + }, + "title": "Zmiana hasła" + }, + "settings": { + "desc": "Zarządzaj profilem, bezpieczeństwem i preferencjami konta.", + "devices": { + "browser": "Przeglądarka", + "current_device": "To urządzenie", + "desc": "Zarządzaj urządzeniami, na których jesteś zalogowany.", + "empty": "Brak aktywnych urządzeń.", + "ip_address": "Adres IP", + "last_active": "Ostatnia aktywność", + "revoke": { + "action": "Wyloguj urządzenie", + "confirm": "Wyloguj", + "desc": "Zostaniesz wylogowany z urządzenia {os}. Aby wrócić, trzeba będzie zalogować się na nim ponownie.", + "success": "Urządzenie zostało wylogowane.", + "title": "Wylogować to urządzenie?" + }, + "session_expires": "Sesja wygasa", + "title": "Urządzenia" + }, + "nav": { + "devices": "Urządzenia", + "overview": "Przegląd", + "security": "Bezpieczeństwo" + }, + "title": "Ustawienia" + } + }, + "files": { + "actions": { + "delete": "Usuń", + "download": "Pobierz" + }, + "bulk_delete": { + "confirm": "Usuń", + "desc": "{count, plural, one {Czy na pewno chcesz usunąć ten plik? Zostanie trwale usunięty i nie będzie można tego cofnąć.} other {Czy na pewno chcesz usunąć te # pliki? Zostaną trwale usunięte i nie będzie można tego cofnąć.}}", + "in_use": { + "content": "{count, plural, one {# plik jest nadal używany przez treść, więc został zachowany. Usuń go najpierw w treści.} few {# pliki są nadal używane przez treść, więc zostały zachowane. Usuń je najpierw w treści.} many {# plików jest nadal używanych przez treść, więc zostały zachowane. Usuń je najpierw w treści.} other {# pliku jest nadal używane przez treść, więc zostało zachowane. Usuń je najpierw w treści.}}", + "revisions": { + "confirm": "Usuń mimo to", + "desc": "{count, plural, one {# plik nie jest już nigdzie wyświetlany, ale zapisane wersje nadal go zachowują. Po usunięciu przywrócenie tych wersji nie przywróci pliku.} few {# pliki nie są już nigdzie wyświetlane, ale zapisane wersje nadal je zachowują. Po usunięciu przywrócenie tych wersji nie przywróci plików.} many {# plików nie jest już nigdzie wyświetlanych, ale zapisane wersje nadal je zachowują. Po usunięciu przywrócenie tych wersji nie przywróci plików.} other {# pliku nie jest już nigdzie wyświetlane, ale zapisane wersje nadal je zachowują. Po usunięciu przywrócenie tych wersji nie przywróci plików.}} Tej operacji nie można cofnąć." + } + }, + "success": "{count, plural, one {Usunięto # plik.} few {Usunięto # pliki.} many {Usunięto # plików.} other {Usunięto # pliku.}}", + "title": "{count, plural, one {Usuń # plik} few {Usuń # pliki} many {Usuń # plików} other {Usuń # pliku}}" + }, + "delete": { + "confirm": "Usuń", + "desc": "Czy na pewno chcesz usunąć ten plik? Zostanie trwale usunięty i nie będzie można tego cofnąć.", + "in_use": { + "content": "Ten plik jest nadal używany przez treść, więc po usunięciu ta treść wskazywałaby na nic. Usuń go najpierw w treści.", + "revisions": { + "confirm": "Usuń mimo to", + "desc": "Ten plik nie jest już nigdzie wyświetlany, ale nadal zachowuje go {count, plural, one {# zapisana wersja} few {# zapisane wersje} many {# zapisanych wersji} other {# zapisanej wersji}}. Po usunięciu przywrócenie tych wersji nie przywróci pliku. Tej operacji nie można cofnąć." + } + }, + "success": "Plik został usunięty.", + "title": "Usuń plik" + }, + "desc": "Pliki przesłane na Twoje konto.", + "download": { + "error": "Nie udało się pobrać pliku." + }, + "list": { + "createdAt": "Przesłano", + "dimensions": "Wymiary", + "folder": "Folder", + "metadata": "Metadane", + "name": "Nazwa", + "preview": "Podgląd", + "size": "Rozmiar" + }, + "metadata": { + "empty": "—", + "title": "Metadane" + }, + "noResults": { + "description": "Przesłane pliki pojawią się tutaj.", + "title": "Brak plików" + }, + "title": "Moje pliki" } } } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4547a54e1..d0c22bcdd 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -11,9 +11,9 @@ import { } from '@tanstack/react-router' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { ThemeScript } from '@vitnode/core/components/theme-script' +import { IntlProvider as CoreIntlProvider } from '@vitnode/core/lib/i18n/provider' import { VitNodeProviders } from '@vitnode/core/views/layouts/providers' import { VitNodeWebSocketProvider } from '@vitnode/core/ws/provider' -import { IntlProvider as NextIntlProvider } from 'next-intl' import { IntlProvider } from 'use-intl' import { RealtimeListeners } from '#/components/realtime-listeners' @@ -93,30 +93,37 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({ * * ## Why the provider is mounted twice * - * `IntlProvider` is one component - `next-intl` re-exports `use-intl`'s, and - * `NextIntlClientProvider` is that same component with a `locale` guard in front - * of it. What differs is the *module record* it was loaded from. This app's - * source goes through Vite's SSR module runner; `@vitnode/core` is external - * (`vite.config.ts`) and so is loaded by Node, and the `use-intl` it reaches - * through `next-intl` is a second instance with its own React context. Proven by - * identity, in a dev render: + * `IntlProvider` is one component, imported from two places, and under + * `vite dev` those are two *module records* with two React contexts. This app's + * source goes through Vite's SSR module runner, which resolves `use-intl` with + * the `development` export condition; `@vitnode/core` is external + * (`vite.config.ts`) and so is loaded by Node, which resolves the same package + * to its `default` (production) build. Same version, same `node_modules` entry, + * two files - and `createContext` runs once per file. Proven by identity, in a + * dev render: * - * IntlProvider (use-intl) === IntlProvider (use-intl/react) -> true - * IntlProvider (use-intl) === IntlProvider (next-intl) -> false + * IntlProvider (use-intl) === IntlProvider (use-intl/react) -> true + * IntlProvider (use-intl) === IntlProvider (core's provider) -> false + * IntlProvider (core provider) === the record core's components read -> true * - * So core's 151 shared components - every `useTranslations` in the design - * system - look for a context this app would otherwise never have provided, and - * the first of them to render throws. A production build happens to bundle both - * into one chunk and collapse the two into one, which is exactly what made this - * a `vite dev`-only 500 that the built server never showed. + * So core's shared components - every `useTranslations` in the design system - + * look for a context this app would otherwise never have provided, and the + * first of them to render throws. A production build bundles both into one + * chunk and collapses the two into one, which is exactly what made this a + * `vite dev`-only 500 that the built server never showed. * - * Both records therefore get the same locale and the same messages. The cost is - * one extra context; the alternative is the app's own code importing from - * `next-intl`, which is the dependency this stage is meant to be shedding. + * The inner one is core's own export rather than `next-intl`'s. Both resolve to + * the same record today - `next-intl` re-exports `use-intl/react`'s provider + * verbatim - but only one of them *says* so: `@vitnode/core/lib/i18n/provider` + * is loaded by whatever loaded the package, which is by construction the record + * core's components read. Reaching through `next-intl` for it was a coincidence + * that happened to hold, and it is the dependency this migration is shedding - + * no module this app renders imports `next-intl` any more. * - * **Stage 4 deletes the inner one**, once `@vitnode/core` imports `use-intl` - * directly and there is only one record left to provide. - * `src/tests/intl-provider.test.ts` fails if it is removed early. + * Both records get the same locale and the same messages, from one object. + * `RouteMessages` mounts the same pair for a route's own namespaces, for the + * same reason. `src/tests/intl-provider.test.ts` fails if either is removed + * while two records still exist. * * Because the locale comes from router state, changing language re-renders this: * new locale, new query key, new messages, no page reload. @@ -144,14 +151,14 @@ function RootComponent() { return ( <IntlProvider {...intlProps}> - <NextIntlProvider {...intlProps}> + <CoreIntlProvider {...intlProps}> <VitNodeProviders config={{ debug, locales: i18n.locales, theme }}> <VitNodeWebSocketProvider> <RealtimeListeners /> <Outlet /> </VitNodeWebSocketProvider> </VitNodeProviders> - </NextIntlProvider> + </CoreIntlProvider> </IntlProvider> ) } 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, `<html lang>` 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, + * `<html lang>` 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() { </h1> <p className="text-muted-foreground leading-relaxed text-pretty"> - 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. </p> </header> @@ -103,9 +107,18 @@ function Home() { </span> </Row> - <Row label="Fallback - core.global.loading, untranslated in Polish"> - <span className="text-sm" data-testid="loading"> - {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. + */} + <Row label="Fallback - core.global.toggle_sidebar, untranslated in Polish"> + <span className="text-sm" data-testid="fallback"> + {t('toggle_sidebar')} </span> </Row> 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('<IntlProvider {...intlProps}>') + expect(source).toMatch( + /import \{ IntlProvider(?: as \w+)? \} from 'use-intl'/, + ) + expect(source).toContain('<IntlProvider {...intlProps}>') }) - 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('<NextIntlProvider {...intlProps}>') + expect(source).toContain('<CoreIntlProvider {...intlProps}>') }) 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 4f17cfb58..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', () => { @@ -680,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/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/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, `<html lang>` 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(`<RouteMessages namespaces={${entry.constant}}>`) + }) + + 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('<RouteMessages namespaces={namespaces}>') + }) +}) + +/** 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<number>( + (total, value) => total + translatedLeaves(value), + 0, + ) + } + + const branch = (messages: unknown, namespace: string): unknown => + namespace + .split('.') + .reduce<unknown>( + (node, key) => (node as Record<string, unknown> | 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/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<T extends DataTableTMin>({ filters, ...props }: DataTableProps<T>) { - const t = useTranslations("core.global"); const hasToolbar = Boolean(search) || Boolean(filters?.length); const allColumns: ColumnDef<T>[] = bulkActions ? [ @@ -149,12 +148,10 @@ export function ContentDataTable<T extends DataTableTMin>({ {customNoResults?.icon ?? <SearchXIcon />} <div className="space-y-2 text-center"> - <h3 className="text-xl font-semibold tracking-tight"> - {customNoResults?.title ?? t("no_results.title")} - </h3> - <p className="text-muted-foreground text-sm"> - {customNoResults?.description ?? t("no_results.desc")} - </p> + <NoResultsDataTable + description={customNoResults?.description} + title={customNoResults?.title} + /> {customNoResults?.footer} </div> </div> 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 ( + <> + <h3 className="text-xl font-semibold tracking-tight"> + {title ?? t("title")} + </h3> + <p className="text-muted-foreground text-sm"> + {description ?? t("desc")} + </p> + </> + ); +}; 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/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<string, string> => { + 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<string, string>(); + 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/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,