Auth UI: login/register pages, OIDC button, route guard (#6) #25

Merged
steve merged 2 commits from phase-1-auth-ui into main 2026-07-18 22:11:21 +00:00
10 changed files with 120 additions and 55 deletions
Showing only changes of commit ae1906e169 - Show all commits
+17
View File
@@ -0,0 +1,17 @@
import { useRouter } from '@tanstack/react-router'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
// Root error boundary: if a route's beforeLoad/loader throws something other
// than a redirect (e.g. /auth/me fails with a network error or a 500), show a
// recoverable message instead of a blank screen. Retrying re-runs the guards.
export function RouteError({ error }: { error: Error }) {
const router = useRouter()
return (
<div className="mx-auto flex min-h-[60vh] w-full max-w-sm flex-col items-center justify-center gap-4 text-center">
<h1 className="text-lg font-semibold text-fg">Something went wrong</h1>
<p className="text-sm text-muted">{errorMessage(error, 'An unexpected error occurred.')}</p>
<Button onClick={() => router.invalidate()}>Try again</Button>
</div>
)
}
+10 -3
View File
1
@@ -21,8 +21,14 @@ export function AppShell() {
const user = me.data const user = me.data
async function onLogout() { async function onLogout() {
Review

🔴 Logout failure is unhandled, leaving stale auth state and no user feedback

correctness, error-handling · flagged by 5 models

  • web/src/components/layout/AppShell.tsx:23-26onLogout awaits logout.mutateAsync() without a try/catch. If the logout request fails (network error, server 5xx), the error becomes an unhandled promise rejection, the me cache is never cleared (the useLogout onSuccess only runs on success), and the UI continues to show the user as signed in with zero feedback. Fix: Wrap logout.mutateAsync() in try/catch; on error, surface it in the UI (e.g., an inline alert) or at l…

🪰 Gadfly · advisory

🔴 **Logout failure is unhandled, leaving stale auth state and no user feedback** _correctness, error-handling · flagged by 5 models_ - **`web/src/components/layout/AppShell.tsx:23-26`** — `onLogout` awaits `logout.mutateAsync()` without a `try/catch`. If the logout request fails (network error, server 5xx), the error becomes an unhandled promise rejection, the `me` cache is **never** cleared (the `useLogout` `onSuccess` only runs on success), and the UI continues to show the user as signed in with zero feedback. **Fix:** Wrap `logout.mutateAsync()` in `try/catch`; on error, surface it in the UI (e.g., an inline alert) or at l… <sub>🪰 Gadfly · advisory</sub>
await logout.mutateAsync() try {
await navigate({ to: '/login' }) await logout.mutateAsync()
await navigate({ to: '/login' })
} catch {
// The logout request failed, so the session is still valid server-side:
// leave the user where they are (the button re-enables for a retry) rather
// than pretending they're signed out. logout.isError drives the title below.
}
} }
return ( return (
@@ -55,9 +61,10 @@ export function AppShell() {
type="button" type="button"
onClick={onLogout} onClick={onLogout}
disabled={logout.isPending} disabled={logout.isPending}
title={logout.isError ? 'Sign out failed — try again' : undefined}
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg disabled:opacity-60" className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg disabled:opacity-60"
> >
{logout.isPending ? 'Signing out…' : 'Sign out'} {logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
</button> </button>
</div> </div>
) : ( ) : (
+12
View File
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
/** A horizontal rule with centered label text (e.g. "or" between auth options). */
export function Divider({ children }: { children: ReactNode }) {
return (
<div className="flex items-center gap-3 text-xs uppercase tracking-wide text-muted">
<span className="h-px flex-1 bg-border" />
{children}
<span className="h-px flex-1 bg-border" />
</div>
)
}
+5 -3
View File
@@ -1,4 +1,4 @@
import { forwardRef, type InputHTMLAttributes } from 'react' import { forwardRef, useId, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> { interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
@@ -7,12 +7,14 @@ interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
} }
// A labelled text input. text-base (16px) is deliberate: smaller fonts make iOS // A labelled text input. text-base (16px) is deliberate: smaller fonts make iOS
// Safari zoom on focus. The label's htmlFor falls back to the field name. // Safari zoom on focus. The field id falls back to the name, then to a generated
// id, so the label/hint associations always hold.
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField( export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
{ label, hint, id, name, className, ...props }, { label, hint, id, name, className, ...props },
ref, ref,
Review

🟠 hintId becomes literal 'undefined-hint' when id and name are both omitted

correctness, error-handling · flagged by 1 model

  • web/src/components/ui/TextField.tsx:15inputId is computed as id ?? name. Both are optional on InputHTMLAttributes, so a caller that omits both (easy to do in a shared UI primitive) yields inputId === undefined. The <label htmlFor> and <input id> are then omitted, breaking the programmatic label–input association, and if hint is present aria-describedby becomes "undefined-hint". Fix: fall back to React.useId() when neither id nor name is supplied.

🪰 Gadfly · advisory

🟠 **hintId becomes literal 'undefined-hint' when id and name are both omitted** _correctness, error-handling · flagged by 1 model_ - **`web/src/components/ui/TextField.tsx:15`** — `inputId` is computed as `id ?? name`. Both are optional on `InputHTMLAttributes`, so a caller that omits both (easy to do in a shared UI primitive) yields `inputId === undefined`. The `<label htmlFor>` and `<input id>` are then omitted, breaking the programmatic label–input association, and if `hint` is present `aria-describedby` becomes `"undefined-hint"`. **Fix:** fall back to `React.useId()` when neither `id` nor `name` is supplied. <sub>🪰 Gadfly · advisory</sub>
) { ) {
const inputId = id ?? name const generatedId = useId()
const inputId = id ?? name ?? generatedId
const hintId = hint ? `${inputId}-hint` : undefined const hintId = hint ? `${inputId}-hint` : undefined
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
+22 -2
View File
@@ -5,7 +5,8 @@
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and, // editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile. // on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
const BASE = '/api/v1' /** Base path for the versioned JSON API; also used to build server-nav links. */
export const API_BASE = '/api/v1'
/** Error thrown for any non-2xx response (or a network failure, status 0). */ /** Error thrown for any non-2xx response (or a network failure, status 0). */
export class ApiError extends Error { export class ApiError extends Error {
@@ -44,7 +45,7 @@ export interface RequestOptions {
} }
function buildUrl(path: string, params?: Params): string { function buildUrl(path: string, params?: Params): string {
const url = BASE + path const url = API_BASE + path
if (!params) return url if (!params) return url
const qs = new URLSearchParams() const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) { for (const [k, v] of Object.entries(params)) {
@@ -112,6 +113,25 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
return payload as T return payload as T
} }
/** The server error code from an ApiError body ({error:{code,message}}), if any. */
export function apiErrorCode(err: unknown): string | null {
if (err instanceof ApiError && err.body && typeof err.body === 'object') {
const e = (err.body as { error?: { code?: unknown } }).error
if (e && typeof e.code === 'string') return e.code
}
return null
}
/**
* A user-facing message for a caught request error. An ApiError carries the
* server's own message (already user-friendly), so that wins; `fallback` covers
* everything else (unexpected throws).
*/
export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string {
if (err instanceof ApiError) return err.message
return fallback
}
/** Convenience verbs over apiFetch. */ /** Convenience verbs over apiFetch. */
export const api = { export const api = {
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) => get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
-15
View File
3
@@ -95,18 +95,3 @@ export function useLogout() {
}, },
}) })
} }
/** The server error code from an ApiError body ({error:{code,message}}), if any. */
export function apiErrorCode(err: unknown): string | null {
if (err instanceof ApiError && err.body && typeof err.body === 'object') {
const e = (err.body as { error?: { code?: unknown } }).error
if (e && typeof e.code === 'string') return e.code
}
return null
}
/** A user-facing message for a caught request error. */
export function errorMessage(err: unknown, fallback = 'Something went wrong.'): string {
if (err instanceof ApiError) return err.message
return fallback
}
3
+14
View File
@@ -0,0 +1,14 @@
// Where to land after auth. The router guard stashes the intended destination
// in ?redirect=, and the login page reads it back; both go through this so a
// caller-controlled value can never send the user to an external origin.
const DEFAULT_DESTINATION = '/gardens'
/**
* Return dest only if it is a same-origin absolute path (starts with a single
* '/'); otherwise fall back to the gardens list. Rejects protocol-relative
* ('//evil.com') and absolute ('https://…') URLs.
*/
export function safeRedirectPath(dest: string | undefined, fallback = DEFAULT_DESTINATION): string {
return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : fallback
}
+8 -20
View File
@@ -1,14 +1,17 @@
import { useState, type FormEvent, type ReactNode } from 'react' import { useState, type FormEvent } from 'react'
import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { Link, useNavigate, useSearch } from '@tanstack/react-router'
import { AuthCard } from '@/components/auth/AuthCard' import { AuthCard } from '@/components/auth/AuthCard'
import { Alert } from '@/components/ui/Alert' import { Alert } from '@/components/ui/Alert'
import { Button, buttonClasses } from '@/components/ui/Button' import { Button, buttonClasses } from '@/components/ui/Button'
import { Divider } from '@/components/ui/Divider'
import { TextField } from '@/components/ui/TextField' import { TextField } from '@/components/ui/TextField'
import { errorMessage, useLogin, useProviders } from '@/lib/auth' import { API_BASE, errorMessage } from '@/lib/api'
import { useLogin, useProviders } from '@/lib/auth'
import { safeRedirectPath } from '@/lib/redirect'
Review

🟡 Hardcoded API base path duplicates lib/api.ts constant

maintainability · flagged by 1 model

  • web/src/pages/LoginPage.tsx:11OIDC_LOGIN_URL hardcodes /api/v1, duplicating the base path already defined in lib/api.ts. If the API base ever changes, this link will break silently. Export BASE from lib/api.ts (or a derived helper) and construct the OIDC URL from it so the prefix lives in one place.

🪰 Gadfly · advisory

🟡 **Hardcoded API base path duplicates lib/api.ts constant** _maintainability · flagged by 1 model_ - **`web/src/pages/LoginPage.tsx:11`** — `OIDC_LOGIN_URL` hardcodes `/api/v1`, duplicating the base path already defined in `lib/api.ts`. If the API base ever changes, this link will break silently. Export `BASE` from `lib/api.ts` (or a derived helper) and construct the OIDC URL from it so the prefix lives in one place. <sub>🪰 Gadfly · advisory</sub>
// Server-initiated redirect (not a fetch): the browser navigates to the Go // Server-initiated redirect (not a fetch): the browser navigates to the Go
// handler, which 302s to the IdP. // handler, which 302s to the IdP.
const OIDC_LOGIN_URL = '/api/v1/auth/oidc/login' const OIDC_LOGIN_URL = `${API_BASE}/auth/oidc/login`
// Messages for the ?error= codes the OIDC callback redirects back with. // Messages for the ?error= codes the OIDC callback redirects back with.
const callbackErrors: Record<string, string> = { const callbackErrors: Record<string, string> = {
@@ -20,11 +23,6 @@ const callbackErrors: Record<string, string> = {
oidc_conflict: 'That single sign-on identity conflicts with an existing account.', oidc_conflict: 'That single sign-on identity conflicts with an existing account.',
} }
Review

🟠 Duplicate redirect sanitization logic with router.tsx

maintainability · flagged by 4 models

  • web/src/pages/LoginPage.tsx:24 and web/src/router.tsx:40safeRedirect and safeInternalPath are identical redirect-sanitization functions. Keeping two copies means any future change (e.g., adding an allow-list of paths, or changing the default fallback) has to be made in both places. Extract one shared utility (e.g. lib/url.ts or lib/auth.ts) and import it in both files.

🪰 Gadfly · advisory

🟠 **Duplicate redirect sanitization logic with router.tsx** _maintainability · flagged by 4 models_ - **`web/src/pages/LoginPage.tsx:24`** and **`web/src/router.tsx:40`** — `safeRedirect` and `safeInternalPath` are identical redirect-sanitization functions. Keeping two copies means any future change (e.g., adding an allow-list of paths, or changing the default fallback) has to be made in both places. Extract one shared utility (e.g. `lib/url.ts` or `lib/auth.ts`) and import it in both files. <sub>🪰 Gadfly · advisory</sub>
// Only follow same-origin absolute paths, never a caller-controlled external URL.
function safeRedirect(dest: string | undefined): string {
return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : '/gardens'
}
export function LoginPage() { export function LoginPage() {
const search = useSearch({ from: '/login' }) const search = useSearch({ from: '/login' })
const navigate = useNavigate() const navigate = useNavigate()
@@ -33,7 +31,7 @@ export function LoginPage() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const dest = safeRedirect(search.redirect) const dest = safeRedirectPath(search.redirect)
const callbackError = search.error ? (callbackErrors[search.error] ?? callbackErrors.oidc) : null const callbackError = search.error ? (callbackErrors[search.error] ?? callbackErrors.oidc) : null
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
Review

🟡 web/src/pages/LoginPage.tsx:37

error-handling · flagged by 1 model

  • web/src/pages/LoginPage.tsx:37callbackErrors[search.error] ?? callbackErrors.oidc falls back to the generic oidc ("Single sign-on did not complete") message for any unknown ?error= code. validateSearch (router.tsx:62-67) accepts any string for error, so an attacker-supplied/typo'd code yields a misleading SSO message even on a non-OIDC flow. Minor UX/edge: better to show a generic fallback or restrict allowed codes. Verified by reading both files.

🪰 Gadfly · advisory

🟡 **`web/src/pages/LoginPage.tsx:37`** _error-handling · flagged by 1 model_ - **`web/src/pages/LoginPage.tsx:37`** — `callbackErrors[search.error] ?? callbackErrors.oidc` falls back to the generic `oidc` ("Single sign-on did not complete") message for any unknown `?error=` code. `validateSearch` (`router.tsx:62-67`) accepts any string for `error`, so an attacker-supplied/typo'd code yields a misleading SSO message even on a non-OIDC flow. Minor UX/edge: better to show a generic fallback or restrict allowed codes. **Verified** by reading both files. <sub>🪰 Gadfly · advisory</sub>
2
@@ -66,7 +64,7 @@ export function LoginPage() {
{oidc && local && <Divider>or</Divider>} {oidc && local && <Divider>or</Divider>}
{local && ( {local && (
<form onSubmit={onSubmit} className="flex flex-col gap-3" noValidate> <form onSubmit={onSubmit} className="flex flex-col gap-3">
<TextField <TextField
label="Email" label="Email"
Review

🟡 noValidate disables required/minLength guards; empty/invalid input is sent to the server with no client-side check

error-handling · flagged by 2 models

  • web/src/pages/LoginPage.tsx:69 and web/src/pages/RegisterPage.tsx:55 (register form) — noValidate silently disables the declared required/minLength guards. Both forms set noValidate but perform no client-side validation before mutateAsync(...), so empty email/displayName and sub-8-char passwords are submitted straight to the network. The server does reject them (shown via errorMessage), so it isn't a crash, but the required/minLength={8} attributes are decorative. Eithe…

🪰 Gadfly · advisory

🟡 **noValidate disables required/minLength guards; empty/invalid input is sent to the server with no client-side check** _error-handling · flagged by 2 models_ - **`web/src/pages/LoginPage.tsx:69` and `web/src/pages/RegisterPage.tsx:55` (register form) — `noValidate` silently disables the declared `required`/`minLength` guards.** Both forms set `noValidate` but perform no client-side validation before `mutateAsync(...)`, so empty email/displayName and sub-8-char passwords are submitted straight to the network. The server does reject them (shown via `errorMessage`), so it isn't a crash, but the `required`/`minLength={8}` attributes are decorative. Eithe… <sub>🪰 Gadfly · advisory</sub>
name="email" name="email"
@@ -110,13 +108,3 @@ export function LoginPage() {
</AuthCard> </AuthCard>
) )
} }
function Divider({ children }: { children: ReactNode }) {
return (
<div className="flex items-center gap-3 text-xs uppercase tracking-wide text-muted">
<span className="h-px flex-1 bg-border" />
{children}
<span className="h-px flex-1 bg-border" />
</div>
)
}
6
+20 -3
View File
@@ -4,7 +4,8 @@ import { AuthCard } from '@/components/auth/AuthCard'
import { Alert } from '@/components/ui/Alert' import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/TextField' import { TextField } from '@/components/ui/TextField'
import { apiErrorCode, errorMessage, useProviders, useRegister } from '@/lib/auth' import { apiErrorCode, errorMessage } from '@/lib/api'
import { useProviders, useRegister } from '@/lib/auth'
const MIN_PASSWORD = 8 const MIN_PASSWORD = 8
@@ -26,8 +27,24 @@ export function RegisterPage() {
} }
} }
// Don't render the form until we know local registration is offered — otherwise
Outdated
Review

🔴 RegisterPage renders form while providers query is pending or errored

correctness, error-handling, maintainability · flagged by 4 models

  • web/src/pages/RegisterPage.tsx:30 — The page immediately falls through to render the full registration form when providers is still loading (isPending) or has errored (isError). Unlike LoginPage, there is no loading spinner or error alert for the providers query, so the form is interactive before the app even knows whether local registration is enabled. Fix: Add early returns for providers.isPending and providers.isError, mirroring the guard pattern in `LoginPage.tsx:57-5…

🪰 Gadfly · advisory

🔴 **RegisterPage renders form while providers query is pending or errored** _correctness, error-handling, maintainability · flagged by 4 models_ - **`web/src/pages/RegisterPage.tsx:30`** — The page immediately falls through to render the full registration form when `providers` is still loading (`isPending`) or has errored (`isError`). Unlike `LoginPage`, there is no loading spinner or error alert for the providers query, so the form is interactive before the app even knows whether local registration is enabled. **Fix:** Add early returns for `providers.isPending` and `providers.isError`, mirroring the guard pattern in `LoginPage.tsx:57-5… <sub>🪰 Gadfly · advisory</sub>
// it briefly appears (and is submittable) on an SSO-only server.
if (providers.isPending) {
return (
<AuthCard title="Create account">
<p className="text-sm text-muted">Loading</p>
</AuthCard>
)
}
if (providers.isError) {
return (
<AuthCard title="Create account">
<Alert>Could not load sign-in options. Please refresh.</Alert>
</AuthCard>
)
}
// Local registration is only meaningful when local auth is enabled. // Local registration is only meaningful when local auth is enabled.
if (providers.isSuccess && !providers.data.local) { if (!providers.data.local) {
return ( return (
<AuthCard title="Create account"> <AuthCard title="Create account">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
1
@@ -52,7 +69,7 @@ export function RegisterPage() {
</Link> </Link>
</div> </div>
) : ( ) : (
<form onSubmit={onSubmit} className="flex flex-col gap-3" noValidate> <form onSubmit={onSubmit} className="flex flex-col gap-3">
<TextField <TextField
label="Email" label="Email"
name="email" name="email"
+12 -9
View File
@@ -6,6 +6,7 @@ import {
} from '@tanstack/react-router' } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
import { AppShell } from '@/components/layout/AppShell' import { AppShell } from '@/components/layout/AppShell'
import { RouteError } from '@/components/RouteError'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage' import { RegisterPage } from '@/pages/RegisterPage'
import { GardensPage } from '@/pages/GardensPage' import { GardensPage } from '@/pages/GardensPage'
@@ -13,19 +14,25 @@ import { GardenEditorPage } from '@/pages/GardenEditorPage'
import { PlantsPage } from '@/pages/PlantsPage' import { PlantsPage } from '@/pages/PlantsPage'
import { meQueryOptions } from '@/lib/auth' import { meQueryOptions } from '@/lib/auth'
import { queryClient } from '@/lib/queryClient' import { queryClient } from '@/lib/queryClient'
import { safeRedirectPath } from '@/lib/redirect'
interface RouterContext { interface RouterContext {
queryClient: QueryClient queryClient: QueryClient
} }
const rootRoute = createRootRouteWithContext<RouterContext>()({ component: AppShell }) const rootRoute = createRootRouteWithContext<RouterContext>()({
component: AppShell,
// A beforeLoad/loader failure that isn't a redirect (e.g. /auth/me errors with
Outdated
Review

🔴 Redirect-after-login broken because location.href (full URL) is passed but safeRedirect expects a path

error-handling · flagged by 2 models

  • web/src/router.tsx:25-30 / :33-38 — a non-401 error in ensureQueryData throws inside beforeLoad with no errorComponent configured. meQueryOptions.queryFn rethrows anything that isn't a 401 (e.g. a network failure, status: 0). The query default retry (3) softens transient blips, but a persistent outage on /gardens makes requireAuth throw a real (non-redirect) error in beforeLoad. rootRoute is created with only { component: AppShell } — no errorComponent/`notFound…

🪰 Gadfly · advisory

🔴 **Redirect-after-login broken because location.href (full URL) is passed but safeRedirect expects a path** _error-handling · flagged by 2 models_ - **`web/src/router.tsx:25-30` / `:33-38` — a non-401 error in `ensureQueryData` throws inside `beforeLoad` with no `errorComponent` configured.** `meQueryOptions.queryFn` rethrows anything that isn't a 401 (e.g. a network failure, `status: 0`). The query default `retry` (3) softens transient blips, but a persistent outage on `/gardens` makes `requireAuth` throw a real (non-redirect) error in `beforeLoad`. `rootRoute` is created with only `{ component: AppShell }` — no `errorComponent`/`notFound… <sub>🪰 Gadfly · advisory</sub>
// a 500 or the network drops) lands here instead of a blank screen.
errorComponent: RouteError,
})
// requireAuth: resolve the current user (shared cache with useMe); send anyone // requireAuth: resolve the current user (shared cache with useMe); send anyone
// unauthenticated to /login, remembering where they were headed. // unauthenticated to /login, remembering the path they were headed to.
async function requireAuth(context: RouterContext, href: string) { async function requireAuth(context: RouterContext, path: string) {
const me = await context.queryClient.ensureQueryData(meQueryOptions) const me = await context.queryClient.ensureQueryData(meQueryOptions)
if (!me) { if (!me) {
throw redirect({ to: '/login', search: { redirect: href } }) throw redirect({ to: '/login', search: { redirect: path } })
} }
} }
1
@@ -37,10 +44,6 @@ async function requireGuest(context: RouterContext, redirectTo: string) {
} }
} }
function safeInternalPath(dest: string | undefined): string {
return dest && dest.startsWith('/') && !dest.startsWith('//') ? dest : '/gardens'
}
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
@@ -65,7 +68,7 @@ const loginRoute = createRoute({
if (typeof search.error === 'string') out.error = search.error if (typeof search.error === 'string') out.error = search.error
return out return out
}, },
beforeLoad: ({ context, search }) => requireGuest(context, safeInternalPath(search.redirect)), beforeLoad: ({ context, search }) => requireGuest(context, safeRedirectPath(search.redirect)),
component: LoginPage, component: LoginPage,
}) })
1