Migrate middleware.ts to proxy.ts for Next.js 16 and fix build errors
Next.js 16 renamed middleware to proxy. Merged session-based auth and API key auth into a single proxy.ts. Also fixed: auth route handler exports, missing card component, Button asChild type errors, signIn return type, Drizzle eq() type narrowing, and useSearchParams suspense boundary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
76cb49e908
commit
3e242c3359
9 changed files with 176 additions and 110 deletions
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
|
|
@ -13,6 +13,14 @@ import { AlertCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<LoginPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPageContent() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -36,15 +44,11 @@ export default function LoginPage() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await signIn("credentials", {
|
await signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
redirectTo: "/app",
|
redirectTo: "/app",
|
||||||
});
|
});
|
||||||
// If signIn returns with ok: false, it means auth failed
|
|
||||||
if (result && !result.ok) {
|
|
||||||
setError("Invalid email or password");
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setError("An error occurred. Please try again.");
|
setError("An error occurred. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { ChartColumn } from "lucide-react";
|
import { ChartColumn } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
@ -19,17 +20,26 @@ export function Navbar() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{session ? (
|
{session ? (
|
||||||
<Button size="sm" className="font-mono text-xs" asChild>
|
<Link
|
||||||
<Link href="/app">Go to App</Link>
|
href="/app"
|
||||||
</Button>
|
className={cn(buttonVariants({ size: "sm" }), "font-mono text-xs")}
|
||||||
|
>
|
||||||
|
Go to App
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="sm" className="font-mono text-xs" asChild>
|
<Link
|
||||||
<Link href="/login">Log in</Link>
|
href="/login"
|
||||||
</Button>
|
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "font-mono text-xs")}
|
||||||
<Button size="sm" className="font-mono text-xs" asChild>
|
>
|
||||||
<Link href="/register">Get Started</Link>
|
Log in
|
||||||
</Button>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className={cn(buttonVariants({ size: "sm" }), "font-mono text-xs")}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Navbar } from "./navbar";
|
import { Navbar } from "./navbar";
|
||||||
import {
|
import {
|
||||||
ChartColumn,
|
ChartColumn,
|
||||||
|
|
@ -36,19 +37,18 @@ export default function LandingPage() {
|
||||||
keyboard-driven workspace.
|
keyboard-driven workspace.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<Button size="lg" className="font-mono text-sm gap-2" asChild>
|
<Link
|
||||||
<Link href="/register">
|
href="/register"
|
||||||
Start Annotating <ArrowRight className="w-4 h-4" />
|
className={cn(buttonVariants({ size: "lg" }), "font-mono text-sm gap-2")}
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="font-mono text-sm"
|
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
<Link href="/app">Try Demo</Link>
|
Start Annotating <ArrowRight className="w-4 h-4" />
|
||||||
</Button>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/app"
|
||||||
|
className={cn(buttonVariants({ size: "lg", variant: "outline" }), "font-mono text-sm")}
|
||||||
|
>
|
||||||
|
Try Demo
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -162,11 +162,12 @@ export default function LandingPage() {
|
||||||
<p className="text-muted-foreground text-sm mb-6">
|
<p className="text-muted-foreground text-sm mb-6">
|
||||||
No credit card required. Start annotating charts in seconds.
|
No credit card required. Start annotating charts in seconds.
|
||||||
</p>
|
</p>
|
||||||
<Button size="lg" className="font-mono text-sm gap-2" asChild>
|
<Link
|
||||||
<Link href="/register">
|
href="/register"
|
||||||
Create Free Account <ArrowRight className="w-4 h-4" />
|
className={cn(buttonVariants({ size: "lg" }), "font-mono text-sm gap-2")}
|
||||||
</Link>
|
>
|
||||||
</Button>
|
Create Free Account <ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { GET, POST } from "@/auth";
|
import { handlers } from "@/auth";
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
|
|
|
||||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
export async function getAuthUser() {
|
export type AuthUser = { id: string; name?: string | null; email?: string | null; image?: string | null };
|
||||||
|
|
||||||
|
export async function getAuthUser(): Promise<AuthUser | null> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
return session?.user ?? null;
|
const user = session?.user;
|
||||||
|
if (!user?.id) return null;
|
||||||
|
return user as AuthUser;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export const middleware = auth((req) => {
|
|
||||||
const { pathname } = req.nextUrl;
|
|
||||||
const isAuthenticated = !!req.auth;
|
|
||||||
|
|
||||||
// Protect /api/* except /api/auth/* and /api/health
|
|
||||||
if (pathname.startsWith("/api/")) {
|
|
||||||
const isAuthRoute = pathname.startsWith("/api/auth/");
|
|
||||||
const isHealthRoute = pathname === "/api/health";
|
|
||||||
|
|
||||||
if (!isAuthRoute && !isHealthRoute && !isAuthenticated) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect authenticated users away from /login and /register
|
|
||||||
if (isAuthenticated && (pathname === "/login" || pathname === "/register")) {
|
|
||||||
return NextResponse.redirect(new URL("/app", req.nextUrl.origin));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protect /app/* routes — redirect unauthenticated users to /login
|
|
||||||
if (pathname.startsWith("/app") && !isAuthenticated) {
|
|
||||||
return NextResponse.redirect(new URL("/login", req.nextUrl.origin));
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
});
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
/*
|
|
||||||
* Match all request paths except:
|
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimisation)
|
|
||||||
* - favicon.ico / favicon.png / favicon.svg
|
|
||||||
* - public assets (top-level files that are not pages)
|
|
||||||
*/
|
|
||||||
"/((?!_next/static|_next/image|favicon\\.ico|favicon\\.png|favicon\\.svg).*)",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
67
src/proxy.ts
67
src/proxy.ts
|
|
@ -1,42 +1,53 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export const proxy = auth((req) => {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
const isAuthenticated = !!req.auth;
|
||||||
|
|
||||||
|
// Protect /api/* except /api/auth/* and /api/health
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
const isAuthRoute = pathname.startsWith("/api/auth/");
|
||||||
|
const isHealthRoute = pathname === "/api/health";
|
||||||
|
|
||||||
|
if (!isAuthRoute && !isHealthRoute && !isAuthenticated) {
|
||||||
|
// Allow external clients with a valid API key
|
||||||
|
const apiKey = process.env.API_KEY;
|
||||||
|
if (apiKey) {
|
||||||
|
const requestApiKey = req.headers.get("X-API-Key");
|
||||||
|
if (requestApiKey === apiKey) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
// Skip auth check for the health endpoint
|
|
||||||
if (pathname === "/api/health") {
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.API_KEY;
|
// Redirect authenticated users away from /login and /register
|
||||||
|
if (isAuthenticated && (pathname === "/login" || pathname === "/register")) {
|
||||||
// If API_KEY is not configured, skip auth check (fail-open for development)
|
return NextResponse.redirect(new URL("/app", req.nextUrl.origin));
|
||||||
if (!apiKey) {
|
|
||||||
console.warn(
|
|
||||||
"Warning: API_KEY environment variable is not set. API authentication is disabled."
|
|
||||||
);
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestApiKey = request.headers.get("X-API-Key");
|
// Protect /app/* routes — redirect unauthenticated users to /login
|
||||||
|
if (pathname.startsWith("/app") && !isAuthenticated) {
|
||||||
// Allow same-origin browser requests (UI -> /api/*) without exposing API_KEY to client JS.
|
return NextResponse.redirect(new URL("/login", req.nextUrl.origin));
|
||||||
// Keep API key auth for non-browser/external clients.
|
|
||||||
const fetchSite = request.headers.get("sec-fetch-site");
|
|
||||||
const isSameOriginBrowserRequest = fetchSite === "same-origin";
|
|
||||||
|
|
||||||
if (isSameOriginBrowserRequest) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!requestApiKey || requestApiKey !== apiKey) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/api/:path*"],
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except:
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimisation)
|
||||||
|
* - favicon.ico / favicon.png / favicon.svg
|
||||||
|
* - public assets (top-level files that are not pages)
|
||||||
|
*/
|
||||||
|
"/((?!_next/static|_next/image|favicon\\.ico|favicon\\.png|favicon\\.svg).*)",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue