Add auth UI: login/register pages, OIDC button, route guard (#6)
Frontend for pansy auth, rendering whatever /auth/providers reports. - lib/auth.ts: zod-validated User/Providers, a shared meQueryOptions (a 401 resolves to null, not an error) reused by useMe() and the router guard, useProviders(), and useLogin/useRegister/useLogout mutations that prime/clear the me cache (logout also drops non-auth cached data). - lib/queryClient.ts: one QueryClient shared by the React tree and the router context so guard and components hit the same cache. - router.tsx: createRootRouteWithContext with the queryClient; requireAuth (redirects to /login?redirect=<dest>) on gardens/plants/editor, and requireGuest on login/register (bounces authed users away). Login search params (redirect, error) validated as optional; redirect targets are restricted to same-origin paths. - pages/LoginPage: OIDC button (label from providers) linking to /auth/oidc/login, "or" divider, local email/password form, and friendly messages for the OIDC callback ?error= codes. RegisterPage: email + display name + password (min 8), registration-closed and SSO-only handling; auto-login lands on /gardens. - AppShell: auth-aware nav — shows the user's display name + Sign out when logged in, Sign in otherwise; nav links only when authed. - ui/: TextField (16px text to avoid iOS zoom, autocomplete + a11y), Button (+ shared buttonClasses for the OIDC anchor), Alert; AuthCard. Verified in a real browser against the embedded binary: register → auto-login → /gardens; refresh stays in; Sign out → /login; a logged-out /gardens/1 redirects to /login and returns after login; OIDC button renders with the Authentik label when configured. tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// Auth data layer: zod-validated shapes for the /api/v1/auth/* endpoints plus
|
||||
// the react-query hooks and shared query options the router guard reuses.
|
||||
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api } from './api'
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.number(),
|
||||
email: z.string(),
|
||||
displayName: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type User = z.infer<typeof userSchema>
|
||||
|
||||
export const providersSchema = z.object({
|
||||
local: z.boolean(),
|
||||
oidc: z.boolean(),
|
||||
oidcLabel: z.string(),
|
||||
})
|
||||
export type Providers = z.infer<typeof providersSchema>
|
||||
|
||||
// meQueryOptions is shared by useMe and the router's beforeLoad guard so both
|
||||
// hit one cache entry. A 401 is the unauthenticated state, not an error, so it
|
||||
// resolves to null rather than throwing (which would surface as a query error).
|
||||
export const meQueryOptions = queryOptions({
|
||||
queryKey: ['auth', 'me'] as const,
|
||||
queryFn: async (): Promise<User | null> => {
|
||||
try {
|
||||
return userSchema.parse(await api.get('/auth/me'))
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.isUnauthorized) return null
|
||||
throw err
|
||||
}
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
export function useMe() {
|
||||
return useQuery(meQueryOptions)
|
||||
}
|
||||
|
||||
export const providersQueryOptions = queryOptions({
|
||||
queryKey: ['auth', 'providers'] as const,
|
||||
queryFn: async (): Promise<Providers> => providersSchema.parse(await api.get('/auth/providers')),
|
||||
staleTime: Infinity, // the enabled methods don't change within a session
|
||||
})
|
||||
|
||||
export function useProviders() {
|
||||
return useQuery(providersQueryOptions)
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
email: string
|
||||
displayName: string
|
||||
password: string
|
||||
}
|
||||
export interface LoginInput {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function useRegister() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: RegisterInput): Promise<User> =>
|
||||
userSchema.parse(await api.post('/auth/register', input)),
|
||||
// Registration auto-logs-in (the server sets the cookie), so prime the me
|
||||
// cache with the returned user instead of a refetch.
|
||||
onSuccess: (user) => qc.setQueryData(meQueryOptions.queryKey, user),
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: LoginInput): Promise<User> =>
|
||||
userSchema.parse(await api.post('/auth/login', input)),
|
||||
onSuccess: (user) => qc.setQueryData(meQueryOptions.queryKey, user),
|
||||
})
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => api.post('/auth/logout'),
|
||||
onSuccess: () => {
|
||||
qc.setQueryData(meQueryOptions.queryKey, null)
|
||||
// Drop any other cached data so the next user never sees the previous
|
||||
// user's gardens/plants. The me/providers entries are re-fetched as needed.
|
||||
qc.removeQueries({ predicate: (q) => q.queryKey[0] !== 'auth' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
// A single QueryClient shared by the React tree (QueryClientProvider) and the
|
||||
// router (as beforeLoad context), so route guards and components read the same
|
||||
// cache — the auth guard's ensureQueryData populates exactly what useMe reads.
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user