Frontend Integration Guide¶
Integrating authentication with Next.js 16 + React 19.
Overview¶
The MBPanel authentication system is designed for Next.js 16 with App Router and React Server Components.
Key Patterns¶
| Pattern | Description |
|---|---|
| HttpOnly Cookies | Tokens stored in HttpOnly cookies (not localStorage) |
| Server Actions | Form submissions use Server Actions (not API routes) |
| Server Components | Default to Server Components (not useEffect) |
| Token Refresh | Automatic background refresh |
| CSRF Protection | Double-submit cookie pattern |
Authentication Flow¶
Client-Side Flow¶
// app/actions/auth.ts
'use server'
import { cookies } from 'next/headers'
export async function loginAction(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const rememberMe = formData.get('remember_me') === 'true'
// Step 1: Verify credentials
const response = await fetch(`${process.env.API_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, remember_me }),
})
if (!response.ok) {
return { error: 'Invalid credentials' }
}
const { pre_auth_token, teams } = await response.json()
// Step 2: User selects team (client-side)
return { pre_auth_token, teams }
}
export async function exchangeSessionAction(
preAuthToken: string,
teamId: number,
sessionInfo?: SessionInfo
) {
const response = await fetch(`${process.env.API_URL}/api/v1/auth/session-exchange`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Cookies handled automatically by browser
},
body: JSON.stringify({
pre_auth_token: preAuthToken,
team_id: teamId,
session_info: sessionInfo,
}),
})
if (!response.ok) {
return { error: 'Session exchange failed' }
}
// Cookies are set automatically by Set-Cookie headers
return { success: true }
}
Login Page Component¶
// app/auth/login/page.tsx
'use client'
import { useState } from 'react'
import { loginAction, exchangeSessionAction } from '@/app/actions/auth'
export function LoginForm() {
const [step, setStep] = useState<'credentials' | 'team'>('credentials')
const [preAuthToken, setPreAuthToken] = useState<string>()
const [teams, setTeams] = useState<Team[]>([])
const handleLogin = async (formData: FormData) => {
const result = await loginAction(formData)
if (result.error) {
setError(result.error)
return
}
setPreAuthToken(result.pre_auth_token)
setTeams(result.teams)
setStep('team')
}
const handleTeamSelect = async (teamId: number) => {
const result = await exchangeSessionAction(preAuthToken!, teamId)
if (result.error) {
setError(result.error)
return
}
router.push('/dashboard')
}
if (step === 'credentials') {
return <CredentialsForm onSubmit={handleLogin} />
}
return <TeamSelector teams={teams} onSelect={handleTeamSelect} />
}
Server-Side Auth Check¶
Server Component Auth¶
// app/dashboard/page.tsx
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const cookieStore = await cookies()
const accessToken = cookieStore.get('mbpanel_access')?.value
if (!accessToken) {
redirect('/login')
}
// Validate token with backend
const response = await fetch(`${process.env.API_URL}/api/v1/auth/me`, {
headers: {
Cookie: `mbpanel_access=${accessToken}`,
},
})
if (!response.ok) {
redirect('/login')
}
const user = await response.json()
return (
<div>
<h1>Welcome, {user.user_name}</h1>
<p>Team: {user.team_name}</p>
<p>Role: {user.role_name}</p>
</div>
)
}
Middleware Auth Check¶
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
// Skip auth for public routes
if (request.nextUrl.pathname.startsWith('/login') ||
request.nextUrl.pathname.startsWith('/register')) {
return NextResponse.next()
}
const accessToken = request.cookies.get('mbpanel_access')?.value
if (!accessToken) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Validate token
const response = await fetch(`${process.env.API_URL}/api/v1/auth/me`, {
headers: {
Cookie: `mbpanel_access=${accessToken}`,
},
})
if (!response.ok) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/sites/:path*', '/settings/:path*'],
}
Token Refresh¶
Background Token Refresh¶
// lib/auth/token-refresh.ts
let refreshPromise: Promise<void> | null = null
export async function refreshAccessToken(): Promise<boolean> {
// Prevent multiple concurrent refreshes
if (refreshPromise) {
await refreshPromise
return true
}
refreshPromise = performRefresh()
try {
await refreshPromise
return true
} finally {
refreshPromise = null
}
}
async function performRefresh(): Promise<void> {
const csrfToken = getCookie('mbpanel_csrf')
const response = await fetch(`${process.env.API_URL}/api/v1/auth/refresh`, {
method: 'POST',
credentials: 'include', // Send cookies
headers: {
'X-CSRF-Token': csrfToken || '',
},
})
if (!response.ok) {
// Token refresh failed - user must re-login
if (response.status === 401) {
window.location.href = '/login'
}
throw new Error('Token refresh failed')
}
// New cookies are set automatically
}
function getCookie(name: string): string | undefined {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) return parts.pop()?.split(';').shift()
return undefined
}
Auto-Refresh on 401¶
// lib/fetcher.ts
export async function authenticatedFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
let response = await fetch(url, {
...options,
credentials: 'include',
})
// If 401, try refresh and retry
if (response.status === 401 && !url.includes('/auth/')) {
const refreshed = await refreshAccessToken()
if (refreshed) {
// Retry original request
response = await fetch(url, {
...options,
credentials: 'include',
})
}
}
return response
}
Session Management¶
Logout¶
// app/actions/auth.ts
export async function logoutAction() {
const csrfToken = getCookie('mbpanel_csrf')
const response = await fetch(`${process.env.API_URL}/api/v1/auth/logout`, {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrfToken || '',
},
})
if (response.ok) {
// Cookies are cleared automatically
redirect('/login')
}
return { error: 'Logout failed' }
}
Session Info Capture¶
// lib/auth/session-info.ts
export async function captureSessionInfo(): Promise<SessionInfo> {
// Get device fingerprint
const fingerprint = await getDeviceFingerprint()
// Get IP via edge function or API
const ip = await getClientIP()
// Get user agent
const userAgent = navigator.userAgent
// Get geo location (optional, with permission)
const geo = await getGeoLocation()
return {
device_fingerprint: fingerprint,
ip,
user_agent: userAgent,
geo,
}
}
async function getDeviceFingerprint(): Promise<string> {
// Simple fingerprint using User Agent + screen info
const data = [
navigator.userAgent,
screen.width,
screen.height,
screen.colorDepth,
new Date().getTimezoneOffset(),
].join('|')
const encoder = new TextEncoder()
const dataBuffer = encoder.encode(data)
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
Permission-Based UI¶
Permission Check Hook¶
// hooks/usePermissions.ts
import { useAuth } from '@/contexts/AuthContext'
export function usePermissions() {
const { user } = useAuth()
const hasPermission = (permission: string): boolean => {
if (!user) return false
if (user.permissions.includes('*')) return true
return user.permissions.includes(permission)
}
const hasAnyPermission = (permissions: string[]): boolean => {
if (!user) return false
if (user.permissions.includes('*')) return true
return permissions.some(p => user.permissions.includes(p))
}
const hasAllPermissions = (permissions: string[]): boolean => {
if (!user) return false
if (user.permissions.includes('*')) return true
return permissions.every(p => user.permissions.includes(p))
}
return { hasPermission, hasAnyPermission, hasAllPermissions }
}
Permission-Gated Component¶
// components/PermissionGate.tsx
import { usePermissions } from '@/hooks/usePermissions'
interface PermissionGateProps {
permission: string
fallback?: React.ReactNode
children: React.ReactNode
}
export function PermissionGate({ permission, fallback, children }: PermissionGateProps) {
const { hasPermission } = usePermissions()
if (!hasPermission(permission)) {
return <>{fallback || <div>You don't have permission</div>}</>
}
return <>{children}</>
}
}
// Usage
<PermissionGate permission="site.create">
<button>Create Site</button>
</PermissionGate>
SSE Integration¶
Authenticated SSE Connection¶
// lib/sse/client.ts
export function connectAuthSSE(teamId: number): EventSource {
const url = new URL(`${process.env.API_URL}/api/v1/events`)
url.searchParams.set('team_id', teamId.toString())
// Cookies are sent automatically by browser
const eventSource = new EventSource(url.toString())
eventSource.onopen = () => {
console.log('SSE connected')
}
eventSource.onerror = (error) => {
console.error('SSE error:', error)
eventSource.close()
}
return eventSource
}
// Usage in component
'use client'
import { useEffect, useState } from 'react'
import { connectAuthSSE } from '@/lib/sse/client'
export function TeamEvents({ teamId }: { teamId: number }) {
const [events, setEvents] = useState<string[]>([])
useEffect(() => {
const eventSource = connectAuthSSE(teamId)
eventSource.addEventListener('auth.login', (e) => {
const data = JSON.parse(e.data)
setEvents(prev => [...prev, `User logged in: ${data.user_name}`])
})
return () => {
eventSource.close()
}
}, [teamId])
return (
<div>
<h2>Team Events</h2>
<ul>{events.map((e, i) => <li key={i}>{e}</li>)}</ul>
</div>
)
}
Error Handling¶
Auth Error Handler¶
// lib/auth/errors.ts
export class AuthError extends Error {
constructor(
message: string,
public code: string,
public status: number
) {
super(message)
this.name = 'AuthError'
}
}
export async function handleAuthResponse(response: Response): Promise<void> {
if (response.status === 401) {
throw new AuthError('Session expired', 'SESSION_EXPIRED', 401)
}
if (response.status === 403) {
const data = await response.json()
throw new AuthError(data.detail, 'PERMISSION_DENIED', 403)
}
if (response.status === 423) {
const data = await response.json()
throw new AuthError(data.detail, 'CONCURRENT_LOGIN', 423)
}
if (!response.ok) {
throw new AuthError('Request failed', 'REQUEST_FAILED', response.status)
}
}
Error Boundary¶
// components/AuthErrorBoundary.tsx
'use client'
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class AuthErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('Auth error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div>
<h2>Authentication Error</h2>
<button onClick={() => (window.location.href = '/login')}>
Return to Login
</button>
</div>
)
}
return this.props.children
}
}
Best Practices¶
Do's¶
✅ Use Server Actions for form submissions ✅ Store tokens in HttpOnly cookies (browser handles this) ✅ Use Server Components by default ✅ Implement automatic token refresh ✅ Handle 401/403 errors gracefully ✅ Use permission-based UI rendering ✅ Protect routes with middleware
Don'ts¶
❌ Store tokens in localStorage ❌ Use useEffect for data fetching (use Server Components) ❌ Manually manage token refresh on every request ❌ Expose tokens in logs or error messages ❌ Bypass permission checks on client ❌ Make auth requests from client-side effects
Related Documentation¶
- Quick Start - Backend setup
- API Reference - Endpoint details
- Auth Flows - Flow diagrams