Ajout authentification JWT complète (app 100% protégée)
Backend: - Nouveau module auth.py avec JWT et password handling - Endpoint /api/auth/login (public) - Endpoint /api/auth/me (protégé) - TOUS les endpoints API protégés par require_auth - Variables env: ADMIN_EMAIL, ADMIN_PASSWORD, JWT_SECRET_KEY - Dependencies: python-jose, passlib Frontend: - Page de login (/login) - AuthGuard component pour redirection automatique - Axios interceptor: ajoute JWT token à chaque requête - Gestion erreur 401: redirect automatique vers /login - Bouton logout dans header - Token stocké dans localStorage Configuration: - .env.example mis à jour avec variables auth - Credentials admin configurables via env Sécurité: - Aucun endpoint public (sauf /api/auth/login et /health) - JWT expiration configurable (24h par défaut) - Password en clair dans env (à améliorer avec hash en prod) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,12 @@ ANALYSIS_NUM_WORKERS=4
|
||||
ESSENTIA_MODELS_PATH=/app/models
|
||||
AUDIO_LIBRARY_PATH=/path/to/your/audio/library
|
||||
|
||||
# Authentication
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
JWT_SECRET_KEY=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRATION_HOURS=24
|
||||
|
||||
# Frontend
|
||||
# API URL accessed by the browser (use port 8001 since backend is mapped to 8001)
|
||||
# For production on a remote server, set this to your server's public URL
|
||||
|
||||
@@ -27,6 +27,10 @@ pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# Authentication
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
|
||||
# Utilities
|
||||
aiofiles==23.2.1
|
||||
httpx==0.26.0
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""FastAPI main application."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from ..utils.config import settings
|
||||
from ..utils.logging import setup_logging, get_logger
|
||||
from ..models.database import engine, Base
|
||||
from ..core.auth import require_auth
|
||||
|
||||
# Import routes
|
||||
from .routes import tracks, search, audio, analyze, similar, stats, library
|
||||
from .routes import tracks, search, audio, analyze, similar, stats, library, auth
|
||||
|
||||
# Setup logging
|
||||
setup_logging()
|
||||
@@ -62,13 +63,17 @@ async def health_check():
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(tracks.router, prefix="/api/tracks", tags=["tracks"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
||||
app.include_router(audio.router, prefix="/api/audio", tags=["audio"])
|
||||
app.include_router(analyze.router, prefix="/api/analyze", tags=["analyze"])
|
||||
app.include_router(similar.router, prefix="/api", tags=["similar"])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
||||
app.include_router(library.router, prefix="/api/library", tags=["library"])
|
||||
# Auth endpoints (public - no auth required)
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
|
||||
# Protected endpoints (auth required for ALL routes)
|
||||
app.include_router(tracks.router, prefix="/api/tracks", tags=["tracks"], dependencies=[Depends(require_auth)])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["search"], dependencies=[Depends(require_auth)])
|
||||
app.include_router(audio.router, prefix="/api/audio", tags=["audio"], dependencies=[Depends(require_auth)])
|
||||
app.include_router(analyze.router, prefix="/api/analyze", tags=["analyze"], dependencies=[Depends(require_auth)])
|
||||
app.include_router(similar.router, prefix="/api", tags=["similar"], dependencies=[Depends(require_auth)])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"], dependencies=[Depends(require_auth)])
|
||||
app.include_router(library.router, prefix="/api/library", tags=["library"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
@app.get("/", tags=["root"])
|
||||
|
||||
82
backend/src/api/routes/auth.py
Normal file
82
backend/src/api/routes/auth.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Authentication endpoints."""
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from ...core.auth import authenticate_user, create_access_token, get_current_user
|
||||
from ...utils.config import settings
|
||||
from ...utils.logging import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request model."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Login response model."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: dict
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User response model."""
|
||||
email: str
|
||||
role: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: LoginRequest):
|
||||
"""Authenticate user and return JWT token.
|
||||
|
||||
Args:
|
||||
request: Login credentials
|
||||
|
||||
Returns:
|
||||
Access token and user info
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if credentials are invalid
|
||||
"""
|
||||
user = authenticate_user(request.email, request.password)
|
||||
|
||||
if not user:
|
||||
logger.warning(f"Failed login attempt for: {request.email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token_expires = timedelta(hours=settings.JWT_EXPIRATION_HOURS)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user["email"], "role": user["role"]},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
logger.info(f"User logged in: {user['email']}")
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(current_user: dict = Depends(get_current_user)):
|
||||
"""Get current authenticated user info.
|
||||
|
||||
Args:
|
||||
current_user: Current user from JWT token
|
||||
|
||||
Returns:
|
||||
User information
|
||||
"""
|
||||
return current_user
|
||||
151
backend/src/core/auth.py
Normal file
151
backend/src/core/auth.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Authentication utilities."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from ..utils.config import settings
|
||||
from ..utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# HTTP Bearer for JWT
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash.
|
||||
|
||||
Args:
|
||||
plain_password: Plain text password
|
||||
hashed_password: Hashed password
|
||||
|
||||
Returns:
|
||||
True if password matches
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Hashed password
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create JWT access token.
|
||||
|
||||
Args:
|
||||
data: Data to encode in token
|
||||
expires_delta: Token expiration time
|
||||
|
||||
Returns:
|
||||
JWT token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRATION_HOURS)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict:
|
||||
"""Verify and decode JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded token payload
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
return payload
|
||||
except JWTError as e:
|
||||
logger.error(f"Token verification failed: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def authenticate_user(email: str, password: str) -> Optional[dict]:
|
||||
"""Authenticate user with email and password.
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
password: User password
|
||||
|
||||
Returns:
|
||||
User data if authenticated, None otherwise
|
||||
"""
|
||||
# Check against admin credentials from environment
|
||||
if email == settings.ADMIN_EMAIL and password == settings.ADMIN_PASSWORD:
|
||||
return {
|
||||
"email": email,
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
||||
"""Get current authenticated user from JWT token.
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer credentials
|
||||
|
||||
Returns:
|
||||
User data from token
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
token = credentials.credentials
|
||||
payload = verify_token(token)
|
||||
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"role": payload.get("role", "user")
|
||||
}
|
||||
|
||||
|
||||
async def require_auth(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
"""Dependency to require authentication.
|
||||
|
||||
Args:
|
||||
current_user: Current user from get_current_user
|
||||
|
||||
Returns:
|
||||
Current user data
|
||||
"""
|
||||
return current_user
|
||||
@@ -21,6 +21,12 @@ class Settings(BaseSettings):
|
||||
ESSENTIA_MODELS_PATH: str = "./models"
|
||||
AUDIO_LIBRARY_PATH: str = "/audio"
|
||||
|
||||
# Authentication
|
||||
ADMIN_EMAIL: str = "admin@example.com"
|
||||
ADMIN_PASSWORD: str = "changeme"
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_EXPIRATION_HOURS: int = 24
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "Audio Classifier API"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import { QueryProvider } from "@/components/providers/QueryProvider"
|
||||
import AuthGuard from "@/components/AuthGuard"
|
||||
import Script from "next/script"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
@@ -23,7 +24,9 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<QueryProvider>
|
||||
<AuthGuard>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
124
frontend/app/login/page.tsx
Normal file
124
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { getApiUrl } from "@/lib/api"
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.detail || "Login failed")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Store token in localStorage
|
||||
localStorage.setItem("access_token", data.access_token)
|
||||
localStorage.setItem("user", JSON.stringify(data.user))
|
||||
|
||||
// Redirect to home
|
||||
router.push("/")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
<div className="bg-white rounded-lg shadow-2xl p-8">
|
||||
{/* Logo/Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Audio Classifier
|
||||
</h1>
|
||||
<p className="text-gray-600">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="admin@example.com"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-gray-400 text-sm mt-6">
|
||||
Audio Classifier v1.0.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTracks, getApiUrl } from "@/lib/api"
|
||||
import { logout, getUser } from "@/lib/auth"
|
||||
import type { FilterParams, Track } from "@/lib/types"
|
||||
import FilterPanel from "@/components/FilterPanel"
|
||||
import AudioPlayer from "@/components/AudioPlayer"
|
||||
@@ -160,6 +161,18 @@ export default function Home() {
|
||||
{tracksData?.total || 0} piste{(tracksData?.total || 0) > 1 ? 's' : ''}
|
||||
</div>
|
||||
|
||||
{/* Logout button */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors flex items-center gap-2"
|
||||
title="Déconnexion"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
|
||||
{/* Rescan button */}
|
||||
<button
|
||||
onClick={handleRescan}
|
||||
|
||||
37
frontend/components/AuthGuard.tsx
Normal file
37
frontend/components/AuthGuard.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { isAuthenticated } from "@/lib/auth"
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Skip auth check for login page
|
||||
if (pathname === "/login") {
|
||||
setIsChecking(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!isAuthenticated()) {
|
||||
router.push("/login")
|
||||
} else {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, [pathname, router])
|
||||
|
||||
// Show loading while checking auth
|
||||
if (isChecking && pathname !== "/login") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
<div className="text-white">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -24,12 +24,38 @@ export function getApiUrl(): string {
|
||||
|
||||
// Create axios instance dynamically to use runtime config
|
||||
function getApiClient() {
|
||||
return axios.create({
|
||||
const client = axios.create({
|
||||
baseURL: getApiUrl(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Add JWT token to requests if available
|
||||
client.interceptors.request.use((config) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Handle 401 errors (redirect to login)
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && typeof window !== 'undefined') {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// Tracks
|
||||
|
||||
34
frontend/lib/auth.ts
Normal file
34
frontend/lib/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Authentication utilities
|
||||
*/
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === "undefined") return null
|
||||
return localStorage.getItem("access_token")
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem("access_token", token)
|
||||
}
|
||||
|
||||
export function removeToken(): void {
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("user")
|
||||
}
|
||||
|
||||
export function getUser(): any | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const user = localStorage.getItem("user")
|
||||
return user ? JSON.parse(user) : null
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return getToken() !== null
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
removeToken()
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login"
|
||||
}
|
||||
}
|
||||
20
frontend/middleware.ts
Normal file
20
frontend/middleware.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Middleware runs on server, can't access localStorage
|
||||
// Auth check will be done client-side in layout.tsx
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user