Skip to content

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