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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/web/src/components/error-actions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<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>
</>
)
}
78 changes: 78 additions & 0 deletions apps/web/src/components/layout/settings-breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -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: <SettingsBreadcrumb … /> }`
* 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 `<MainBreadcrumb />` 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 (
<BreadcrumbMainContent
labels={{
[SETTINGS_ROOT_HREF]: t('title'),
...(navKey ? { [href]: tNav(navKey) } : {}),
}}
LinkComponent={MigrationLink}
// Derived from the href for the same reason the labels are keyed by it:
// `resolveMainBreadcrumb` rebuilds a cumulative path per segment, and two
// independent spellings of the same route would silently stop matching.
segments={href.split('/').filter(Boolean)}
/>
)
}

export const SettingsBreadcrumb = ({ navKey }: { navKey?: SettingsNavKey }) => (
<RouteMessages namespaces={SETTINGS_NAMESPACES}>
<SettingsBreadcrumbTrail navKey={navKey} />
</RouteMessages>
)
22 changes: 13 additions & 9 deletions apps/web/src/components/migration-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Link>` 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/<code>`, whatever a plugin indexed.
* Handing every internal-looking path to `<Link>` 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.
*
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/realtime-listeners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
20 changes: 13 additions & 7 deletions apps/web/src/components/route-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
124 changes: 120 additions & 4 deletions apps/web/src/lib/auth/actions.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -7,15 +10,27 @@ 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,
setSessionData,
} from '#/lib/auth/query'
import {
anonymousSession,
changePasswordFormResult,
passwordResetFormResult,
signInFormResult,
signUpFormResult,
ssoCallbackResult,
ssoStartFeedback,
} from '#/lib/auth/screens'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 }))
Loading