Appearance
Authentication Frontend Implementation
Frontend authentication implementation using Better Auth React client, React Query for state management, and TanStack Router for route protection.
Overview
The frontend authentication is provided through the centralized @jubiloop/api-client package:
- Better Auth client for authentication operations (configured in api-client)
- React Query for auth state management (hooks from api-client)
- TanStack Router guards for protected routes
- Form components for login/registration
- Organization support with role-based access
Authentication State Management
React Query as Auth State
Authentication is managed through the @jubiloop/api-client package hooks, providing a consistent interface across all apps:
typescript
// apps/webapp/src/hooks/auth/use-auth.ts
import { api } from '@/lib/api/setup'
export function useAuth() {
// Uses the centralized auth hooks from @jubiloop/api-client
return api.hooks.auth.useAuth()
}
// The actual implementation is in packages/api-client/src/hooks/auth.ts
// providing consistent behavior across all frontend appsSession Query Configuration
Session queries are now centralized in the @jubiloop/api-client package:
typescript
// packages/api-client/src/queries/session.ts
// Uses React Query defaults for optimal performance:
// - staleTime: Infinity (auth state doesn't go stale)
// - gcTime: 5 minutes (default garbage collection)
// - refetchOnMount: true (verify auth on mount)
// - refetchOnWindowFocus: true (security check on return)This simplified configuration:
- Leverages React Query's smart defaults
- Reduces configuration overhead
- Ensures consistent behavior across all apps
- Updates automatically on login/logout
Organization State Management
Query keys are centralized in the @jubiloop/api-client package and re-exported by webapp:
typescript
// packages/api-client/src/keys/defaults.ts
export const defaultQueryKeys = {
all: ['api'] as const,
auth: {
all: ['api', 'auth'] as const,
session: () => ['api', 'auth', 'session'] as const,
organizations: {
all: ['api', 'auth', 'organizations'] as const,
list: () => ['api', 'auth', 'organizations', 'list'] as const,
active: () => ['api', 'auth', 'organizations', 'active'] as const,
detail: (id: string) => ['api', 'auth', 'organizations', 'detail', id] as const,
},
},
}
// apps/webapp/src/integrations/tanstack-query/keys.ts
import { defaultQueryKeys } from '@jubiloop/api-client/keys/defaults'
export const queryKeys = defaultQueryKeys // Re-export for convenienceThis centralization ensures:
- Consistent cache keys across all apps
- Granular cache invalidation
- No accidental key collisions
API Client Setup
Authentication is configured through the centralized API package:
typescript
// apps/webapp/src/lib/api/setup.ts
import { createApiPackage } from '@jubiloop/api-client'
export const api = createApiPackage({
baseURL: import.meta.env.VITE_API_URL || 'https://api.jubiloop.localhost',
onUnauthorized: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.auth.all })
},
})
// The Better Auth client is created internally in packages/api-client/src/auth/client.ts
// with organization plugin already configuredAuthentication Flows
Sign In Flow
1. Sign In Form Component
Located in src/components/auth/SignInForm.tsx:
typescript
const SignInForm = () => {
const { signIn, isLoading } = useAuth()
const onSubmit = (data: TSignInFormValues) => {
signIn(data, {
onSuccess: () => {
// Optional success callback
},
onError: (error) => {
// Optional error callback
},
})
}
}2. Authentication Process
The sign-in flow:
- Better Auth client sends credentials to
/auth/sign-in - Backend validates credentials and creates session
- Session stored in Redis with secure cookie
- React Query invalidates auth queries
- All components using
useAuth()automatically update - Router redirects to dashboard
3. Session Management
Sessions are managed server-side with:
- HTTP-only, Secure, SameSite cookies
- Redis storage for horizontal scaling
- Configurable expiration (7 days default, 30 days with remember me)
- Automatic renewal on activity
Sign Out Flow
typescript
const { signOut } = useAuth()
// Sign out with optional callbacks
signOut({
onSuccess: () => {
// Optional: Additional cleanup
},
onError: (error) => {
// Handle error
},
})The sign-out process:
- Calls Better Auth's sign-out endpoint
- Clears session on backend
- Invalidates all auth queries
- Router redirects to sign-in page
Protected Routes
Route protection is handled in src/routes/__root.tsx:
typescript
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ location, context }) => {
const isAuthRoute =
location.pathname.startsWith('/sign-in') || location.pathname.startsWith('/sign-up')
// Get session from cache or fetch if needed
const sessionData = await getSessionData(context.queryClient)
const authenticated = isAuthenticated(sessionData)
// Redirect logic
if (isAuthRoute && authenticated) {
throw redirect({ to: '/dashboard' })
}
if (!isAuthRoute && !authenticated) {
throw redirect({
to: '/sign-in',
search: { redirect: location.href },
})
}
},
})The route guard pattern:
- Uses query to check auth state
- Checks cache first to avoid unnecessary API calls
- Only fetches from API if no cached session exists
- Redirects based on authentication status
Session Validation
Session validation happens automatically through React Query:
typescript
// src/lib/auth/queries/session.ts
export const getSessionData = async (queryClient: QueryClient) => {
// Try to get from cache first
const cachedData = queryClient.getQueryData(queryKeys.auth.session())
if (cachedData) return cachedData
// If not in cache, fetch from API
try {
return await queryClient.fetchQuery(sessionQueryOptions())
} catch {
return null
}
}Registration Flow
Sign Up Form
Located in src/components/auth/SignUpForm.tsx:
typescript
const SignUpForm = () => {
const { signUp, isLoading } = useAuth()
const onSubmit = (data: TSignUpFormValues) => {
signUp(data, {
onSuccess: () => {
// Auto-redirects to dashboard
},
onError: (error) => {
// Handle error
},
})
}
}Form Validation
Using React Hook Form with Zod:
typescript
const signUpSchema = z
.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/),
name: z.string().min(1, 'Name is required'),
passwordConfirmation: z.string(),
})
.refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords don't match",
path: ['passwordConfirmation'],
})Remember Me Feature
The "Remember Me" checkbox extends session duration:
typescript
// In login form
<Checkbox
name="rememberMe"
label="Remember me for 30 days"
/>
// Better Auth handles session duration based on rememberMe
// - true: Session lasts 30 days
// - false: Session lasts 7 days (default)
// Configuration in apps/server/app/lib/auth.tsError Handling
Invalid Credentials
Error handling is built into the auth hooks:
typescript
const { signIn } = useAuth()
signIn(credentials, {
onError: (error) => {
// Better Auth provides detailed error messages
toast.error(error.message || 'Invalid credentials')
},
})Session Expired
When a session expires:
- API returns 401 status
- API client interceptor detects unauthorized response
- All auth queries are invalidated
- User automatically redirected to sign-in
- Original URL preserved for post-login redirect
Security Best Practices
- HTTP-Only Cookies: Session tokens not accessible via JavaScript
- CSRF Protection: SameSite cookie attribute
- Secure Flag: Cookies only sent over HTTPS in production
- Password Requirements: Minimum 8 characters enforced
- Rate Limiting: Login attempts limited on backend