diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 9cefee5..7515f20 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -5,7 +5,8 @@
// 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.
-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). */
export class ApiError extends Error {
@@ -44,7 +45,7 @@ export interface RequestOptions {
}
function buildUrl(path: string, params?: Params): string {
- const url = BASE + path
+ const url = API_BASE + path
if (!params) return url
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
@@ -112,6 +113,25 @@ export async function apiFetch
(path: string, opts: RequestOptions = {}): Prom
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. */
export const api = {
get: (path: string, opts?: Omit) =>
diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts
index 85d4996..75ea94e 100644
--- a/web/src/lib/auth.ts
+++ b/web/src/lib/auth.ts
@@ -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
-}
diff --git a/web/src/lib/redirect.ts b/web/src/lib/redirect.ts
new file mode 100644
index 0000000..bf28804
--- /dev/null
+++ b/web/src/lib/redirect.ts
@@ -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
+}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
index 550f656..884918a 100644
--- a/web/src/pages/LoginPage.tsx
+++ b/web/src/pages/LoginPage.tsx
@@ -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 { AuthCard } from '@/components/auth/AuthCard'
import { Alert } from '@/components/ui/Alert'
import { Button, buttonClasses } from '@/components/ui/Button'
+import { Divider } from '@/components/ui/Divider'
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'
// Server-initiated redirect (not a fetch): the browser navigates to the Go
// 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.
const callbackErrors: Record = {
@@ -20,11 +23,6 @@ const callbackErrors: Record = {
oidc_conflict: 'That single sign-on identity conflicts with an existing account.',
}
-// 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() {
const search = useSearch({ from: '/login' })
const navigate = useNavigate()
@@ -33,7 +31,7 @@ export function LoginPage() {
const [email, setEmail] = 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
async function onSubmit(e: FormEvent) {
@@ -66,7 +64,7 @@ export function LoginPage() {
{oidc && local && or}
{local && (
-