feat: comprehensive boilerplate improvements

Security & Stability:
- Add Next.js 16 proxy.ts for BetterAuth cookie-based auth protection
- Add rate limiting for API routes (src/lib/rate-limit.ts)
- Add Zod validation for chat API request bodies
- Add session auth check to chat and diagnostics endpoints
- Add security headers in next.config.ts (CSP, X-Frame-Options, etc.)
- Add file upload validation and sanitization in storage.ts

Core UX Components:
- Add error boundaries (error.tsx, not-found.tsx, chat/error.tsx)
- Add loading states (skeleton.tsx, spinner.tsx, loading.tsx files)
- Add toast notifications with Sonner
- Add form components (input.tsx, textarea.tsx, label.tsx)
- Add database indexes for performance (schema.ts)
- Enhance chat UX: timestamps, copy-to-clipboard, thinking indicator,
  error display, localStorage message persistence

Polish & Accessibility:
- Add Open Graph and Twitter card metadata
- Add JSON-LD structured data for SEO
- Add sitemap.ts, robots.ts, manifest.ts
- Add skip-to-content link and ARIA labels in site-header
- Enable profile page quick action buttons with dialogs
- Update Next.js 15 references to Next.js 16

Developer Experience:
- Add GitHub Actions CI workflow (lint, typecheck, build)
- Add Prettier configuration (.prettierrc, .prettierignore)
- Add .nvmrc pinning Node 20
- Add ESLint rules: import/order, react-hooks/exhaustive-deps
- Add stricter TypeScript settings (exactOptionalPropertyTypes,
  noImplicitOverride)
- Add interactive setup script (scripts/setup.ts)
- Add session utility functions (src/lib/session.ts)

All changes mirrored to create-agentic-app/template/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Leon van Zyl
2025-11-30 14:46:15 +02:00
parent 1121258238
commit a3a151c67a
125 changed files with 5088 additions and 493 deletions

View File

@@ -1,4 +1,4 @@
import { auth } from "@/lib/auth"
import { toNextJsHandler } from "better-auth/next-js"
import { auth } from "@/lib/auth"
export const { GET, POST } = toNextJsHandler(auth)

View File

@@ -1,13 +1,85 @@
import { headers } from "next/headers";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { streamText, UIMessage, convertToModelMessages } from "ai";
import { z } from "zod";
import { auth } from "@/lib/auth";
import {
checkRateLimit,
createRateLimitResponse,
rateLimitConfigs,
} from "@/lib/rate-limit";
// Zod schema for message validation
const messagePartSchema = z.object({
type: z.string(),
text: z.string().max(10000, "Message text too long").optional(),
});
const messageSchema = z.object({
id: z.string().optional(),
role: z.enum(["user", "assistant", "system"]),
parts: z.array(messagePartSchema).optional(),
content: z.union([z.string(), z.array(messagePartSchema)]).optional(),
});
const chatRequestSchema = z.object({
messages: z.array(messageSchema).max(100, "Too many messages"),
});
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
// Verify user is authenticated
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Rate limiting by user ID
const rateLimitKey = `chat:${session.user.id}`;
const rateLimit = checkRateLimit(rateLimitKey, rateLimitConfigs.chat);
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit.resetTime);
}
// Parse and validate request body
let body: unknown;
try {
body = await req.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const parsed = chatRequestSchema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify({
error: "Invalid request",
details: parsed.error.flatten().fieldErrors,
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const { messages }: { messages: UIMessage[] } = parsed.data as { messages: UIMessage[] };
// Initialize OpenRouter with API key from environment
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ error: "OpenRouter API key not configured" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
const openrouter = createOpenRouter({ apiKey });
const result = streamText({
model: openrouter(process.env.OPENROUTER_MODEL || "openai/gpt-5-mini"),

View File

@@ -1,4 +1,6 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
type StatusLevel = "ok" | "warn" | "error";
@@ -32,6 +34,14 @@ interface DiagnosticsResponse {
}
export async function GET(req: Request) {
// Require authentication for diagnostics endpoint
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json(
{ error: "Unauthorized. Please sign in to access diagnostics." },
{ status: 401 }
);
}
const env = {
POSTGRES_URL: Boolean(process.env.POSTGRES_URL),
BETTER_AUTH_SECRET: Boolean(process.env.BETTER_AUTH_SECRET),
@@ -137,7 +147,7 @@ export async function GET(req: Request) {
database: {
connected: dbConnected,
schemaApplied,
error: dbError,
...(dbError !== undefined && { error: dbError }),
},
auth: {
configured: authConfigured,

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { MessageSquareWarning, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function ChatError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Chat error:", error);
}, [error]);
return (
<div className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto text-center">
<div className="flex justify-center mb-6">
<MessageSquareWarning className="h-16 w-16 text-destructive" />
</div>
<h1 className="text-2xl font-bold mb-4">Chat Error</h1>
<p className="text-muted-foreground mb-6">
There was a problem with the chat service. This could be due to a
connection issue or the AI service being temporarily unavailable.
</p>
{error.message && (
<p className="text-sm text-muted-foreground mb-4 p-2 bg-muted rounded">
{error.message}
</p>
)}
<div className="flex gap-4 justify-center">
<Button onClick={reset}>
<RefreshCw className="h-4 w-4 mr-2" />
Try again
</Button>
<Button variant="outline" onClick={() => (window.location.href = "/")}>
Go home
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ChatLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
{/* Header skeleton */}
<div className="flex justify-between items-center mb-6 pb-4 border-b">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-40" />
</div>
{/* Messages skeleton */}
<div className="min-h-[50vh] space-y-4 mb-4">
{/* AI message */}
<div className="max-w-[80%]">
<Skeleton className="h-4 w-12 mb-2" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
{/* User message */}
<div className="max-w-[80%] ml-auto">
<Skeleton className="h-4 w-12 mb-2 ml-auto" />
<Skeleton className="h-12 w-full rounded-lg" />
</div>
{/* AI message */}
<div className="max-w-[80%]">
<Skeleton className="h-4 w-12 mb-2" />
<Skeleton className="h-32 w-full rounded-lg" />
</div>
</div>
{/* Input skeleton */}
<div className="flex gap-2">
<Skeleton className="flex-1 h-10 rounded-md" />
<Skeleton className="h-10 w-20 rounded-md" />
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,13 @@
"use client";
import { useState, useEffect, type ReactNode } from "react";
import { useChat } from "@ai-sdk/react";
import { Button } from "@/components/ui/button";
import { UserProfile } from "@/components/auth/user-profile";
import { useSession } from "@/lib/auth-client";
import { useState, type ReactNode } from "react";
import { Copy, Check, Loader2 } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { UserProfile } from "@/components/auth/user-profile";
import { Button } from "@/components/ui/button";
import { useSession } from "@/lib/auth-client";
import type { Components } from "react-markdown";
const H1: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
@@ -110,6 +112,18 @@ type MaybePartsMessage = {
content?: TextPart[];
};
function getMessageText(message: MaybePartsMessage): string {
const parts = Array.isArray(message.parts)
? message.parts
: Array.isArray(message.content)
? message.content
: [];
return parts
.filter((p) => p?.type === "text" && p.text)
.map((p) => p.text)
.join("\n");
}
function renderMessageContent(message: MaybePartsMessage): ReactNode {
if (message.display) return message.display;
const parts = Array.isArray(message.parts)
@@ -126,11 +140,93 @@ function renderMessageContent(message: MaybePartsMessage): ReactNode {
);
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy");
}
};
return (
<button
onClick={handleCopy}
className="p-1 hover:bg-muted rounded transition-colors"
title="Copy to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
);
}
function ThinkingIndicator() {
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted max-w-[80%]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">AI is thinking...</span>
</div>
);
}
const STORAGE_KEY = "chat-messages";
export default function ChatPage() {
const { data: session, isPending } = useSession();
const { messages, sendMessage, status } = useChat();
const { messages, sendMessage, status, error, setMessages } = useChat({
onError: (err) => {
toast.error(err.message || "Failed to send message");
},
});
const [input, setInput] = useState("");
// Load messages from localStorage on mount
useEffect(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed);
}
} catch {
// Invalid JSON, ignore
}
}
}
}, [setMessages]);
// Save messages to localStorage when they change
useEffect(() => {
if (typeof window !== "undefined" && messages.length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
}
}, [messages]);
const clearMessages = () => {
setMessages([]);
localStorage.removeItem(STORAGE_KEY);
toast.success("Chat cleared");
};
if (isPending) {
return <div className="container mx-auto px-4 py-12">Loading...</div>;
}
@@ -145,37 +241,77 @@ export default function ChatPage() {
);
}
const isStreaming = status === "streaming";
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6 pb-4 border-b">
<h1 className="text-2xl font-bold">AI Chat</h1>
<span className="text-sm text-muted-foreground">
Welcome, {session.user.name}!
</span>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Welcome, {session.user.name}!
</span>
{messages.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearMessages}>
Clear chat
</Button>
)}
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">
Error: {error.message || "Something went wrong"}
</p>
</div>
)}
<div className="min-h-[50vh] overflow-y-auto space-y-4 mb-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground">
<div className="text-center text-muted-foreground py-12">
Start a conversation with AI
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`p-3 rounded-lg ${
message.role === "user"
? "bg-primary text-primary-foreground ml-auto max-w-[80%]"
: "bg-muted max-w-[80%]"
}`}
>
<div className="text-sm font-medium mb-1">
{message.role === "user" ? "You" : "AI"}
{messages.map((message) => {
const messageText = getMessageText(message as MaybePartsMessage);
const createdAt = (message as { createdAt?: Date }).createdAt;
const timestamp = createdAt
? formatTimestamp(new Date(createdAt))
: null;
return (
<div
key={message.id}
className={`group p-3 rounded-lg ${
message.role === "user"
? "bg-primary text-primary-foreground ml-auto max-w-[80%]"
: "bg-muted max-w-[80%]"
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{message.role === "user" ? "You" : "AI"}
</span>
{timestamp && (
<span className="text-xs opacity-60">{timestamp}</span>
)}
</div>
{message.role === "assistant" && messageText && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<CopyButton text={messageText} />
</div>
)}
</div>
<div>{renderMessageContent(message as MaybePartsMessage)}</div>
</div>
<div>{renderMessageContent(message as MaybePartsMessage)}</div>
</div>
))}
);
})}
{isStreaming && messages[messages.length - 1]?.role === "user" && (
<ThinkingIndicator />
)}
</div>
<form
@@ -193,12 +329,17 @@ export default function ChatPage() {
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="flex-1 p-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isStreaming}
/>
<Button
type="submit"
disabled={!input.trim() || status === "streaming"}
>
Send
<Button type="submit" disabled={!input.trim() || isStreaming}>
{isStreaming ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending
</>
) : (
"Send"
)}
</Button>
</form>
</div>

View File

@@ -0,0 +1,63 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function DashboardLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
{/* Stats cards */}
<div className="grid gap-4 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="p-6 border rounded-lg">
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
{/* Profile card */}
<div className="p-6 border rounded-lg space-y-4">
<div className="flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
</div>
</div>
<Skeleton className="h-px w-full" />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</div>
{/* Recent activity */}
<div className="space-y-4">
<Skeleton className="h-6 w-36" />
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4 p-4 border rounded-lg">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
"use client";
import { useSession } from "@/lib/auth-client";
import Link from "next/link";
import { Lock } from "lucide-react";
import { UserProfile } from "@/components/auth/user-profile";
import { Button } from "@/components/ui/button";
import { Lock } from "lucide-react";
import { useDiagnostics } from "@/hooks/use-diagnostics";
import Link from "next/link";
import { useSession } from "@/lib/auth-client";
export default function DashboardPage() {
const { data: session, isPending } = useSession();

View File

@@ -0,0 +1,44 @@
"use client";
import { useEffect } from "react";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error("Application error:", error);
}, [error]);
return (
<div className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto text-center">
<div className="flex justify-center mb-6">
<AlertCircle className="h-16 w-16 text-destructive" />
</div>
<h1 className="text-2xl font-bold mb-4">Something went wrong</h1>
<p className="text-muted-foreground mb-6">
An unexpected error occurred. Please try again or contact support if
the problem persists.
</p>
{error.digest && (
<p className="text-xs text-muted-foreground mb-4">
Error ID: {error.digest}
</p>
)}
<div className="flex gap-4 justify-center">
<Button onClick={reset}>Try again</Button>
<Button variant="outline" onClick={() => (window.location.href = "/")}>
Go home
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { ThemeProvider } from "@/components/theme-provider";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import type { Metadata } from "next";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -16,9 +17,62 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Agentic Coding Boilerplate",
title: {
default: "Agentic Coding Boilerplate",
template: "%s | Agentic Coding Boilerplate",
},
description:
"Complete agentic coding boilerplate with authentication, database, AI integration, and modern tooling - perfect for building AI-powered applications and autonomous agents by Leon van Zyl",
keywords: [
"Next.js",
"React",
"TypeScript",
"AI",
"OpenRouter",
"Boilerplate",
"Authentication",
"PostgreSQL",
],
authors: [{ name: "Leon van Zyl" }],
creator: "Leon van Zyl",
openGraph: {
type: "website",
locale: "en_US",
siteName: "Agentic Coding Boilerplate",
title: "Agentic Coding Boilerplate",
description:
"Complete agentic coding boilerplate with authentication, database, AI integration, and modern tooling",
},
twitter: {
card: "summary_large_image",
title: "Agentic Coding Boilerplate",
description:
"Complete agentic coding boilerplate with authentication, database, AI integration, and modern tooling",
},
robots: {
index: true,
follow: true,
},
};
// JSON-LD structured data for SEO
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "Agentic Coding Boilerplate",
description:
"Complete agentic coding boilerplate with authentication, database, AI integration, and modern tooling",
applicationCategory: "DeveloperApplication",
operatingSystem: "Any",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
author: {
"@type": "Person",
name: "Leon van Zyl",
},
};
export default function RootLayout({
@@ -28,6 +82,12 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
@@ -38,8 +98,9 @@ export default function RootLayout({
disableTransitionOnChange
>
<SiteHeader />
{children}
<main id="main-content">{children}</main>
<SiteFooter />
<Toaster richColors position="top-right" />
</ThemeProvider>
</body>
</html>

View File

@@ -0,0 +1,21 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Agentic Coding Boilerplate",
short_name: "Agentic",
description:
"Complete agentic coding boilerplate with authentication, database, AI integration, and modern tooling",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#000000",
icons: [
{
src: "/favicon.ico",
sizes: "any",
type: "image/x-icon",
},
],
};
}

View File

@@ -0,0 +1,28 @@
import Link from "next/link";
import { FileQuestion } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="container mx-auto px-4 py-16">
<div className="max-w-md mx-auto text-center">
<div className="flex justify-center mb-6">
<FileQuestion className="h-16 w-16 text-muted-foreground" />
</div>
<h1 className="text-4xl font-bold mb-4">404</h1>
<h2 className="text-xl font-semibold mb-4">Page Not Found</h2>
<p className="text-muted-foreground mb-6">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className="flex gap-4 justify-center">
<Button asChild>
<Link href="/">Go home</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/dashboard">Dashboard</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { SetupChecklist } from "@/components/setup-checklist";
import { useDiagnostics } from "@/hooks/use-diagnostics";
import { StarterPromptModal } from "@/components/starter-prompt-modal";
import { Video, Shield, Database, Palette, Bot } from "lucide-react";
import { SetupChecklist } from "@/components/setup-checklist";
import { StarterPromptModal } from "@/components/starter-prompt-modal";
import { Button } from "@/components/ui/button";
import { useDiagnostics } from "@/hooks/use-diagnostics";
export default function Home() {
const { isAuthReady, isAiReady, loading } = useDiagnostics();

View File

@@ -1,17 +1,37 @@
"use client";
import { useSession } from "@/lib/auth-client";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Mail, Calendar, User, Shield, ArrowLeft } from "lucide-react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Mail, Calendar, User, Shield, ArrowLeft, Lock, Smartphone } from "lucide-react";
import { toast } from "sonner";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useSession } from "@/lib/auth-client";
export default function ProfilePage() {
const { data: session, isPending } = useSession();
const router = useRouter();
const [editProfileOpen, setEditProfileOpen] = useState(false);
const [securityOpen, setSecurityOpen] = useState(false);
const [emailPrefsOpen, setEmailPrefsOpen] = useState(false);
if (isPending) {
return (
@@ -27,11 +47,20 @@ export default function ProfilePage() {
}
const user = session.user;
const createdDate = user.createdAt ? new Date(user.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : null;
const createdDate = user.createdAt
? new Date(user.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
const handleEditProfileSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// In a real app, this would call an API to update the user profile
toast.info("Profile updates require backend implementation");
setEditProfileOpen(false);
};
return (
<div className="container max-w-4xl mx-auto py-8 px-4">
@@ -60,11 +89,7 @@ export default function ProfilePage() {
referrerPolicy="no-referrer"
/>
<AvatarFallback className="text-lg">
{(
user.name?.[0] ||
user.email?.[0] ||
"U"
).toUpperCase()}
{(user.name?.[0] || user.email?.[0] || "U").toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-2">
@@ -73,7 +98,10 @@ export default function ProfilePage() {
<Mail className="h-4 w-4" />
<span>{user.email}</span>
{user.emailVerified && (
<Badge variant="outline" className="text-green-600 border-green-600">
<Badge
variant="outline"
className="text-green-600 border-green-600"
>
<Shield className="h-3 w-3 mr-1" />
Verified
</Badge>
@@ -94,9 +122,7 @@ export default function ProfilePage() {
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>
Your account details and settings
</CardDescription>
<CardDescription>Your account details and settings</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -115,16 +141,19 @@ export default function ProfilePage() {
<div className="p-3 border rounded-md bg-muted/10 flex items-center justify-between">
<span>{user.email}</span>
{user.emailVerified && (
<Badge variant="outline" className="text-green-600 border-green-600">
<Badge
variant="outline"
className="text-green-600 border-green-600"
>
Verified
</Badge>
)}
</div>
</div>
</div>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-medium">Account Status</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -171,7 +200,10 @@ export default function ProfilePage() {
<p className="text-sm text-muted-foreground">Active now</p>
</div>
</div>
<Badge variant="outline" className="text-green-600 border-green-600">
<Badge
variant="outline"
className="text-green-600 border-green-600"
>
Active
</Badge>
</div>
@@ -189,34 +221,195 @@ export default function ProfilePage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button variant="outline" className="justify-start h-auto p-4" disabled>
<Button
variant="outline"
className="justify-start h-auto p-4"
onClick={() => setEditProfileOpen(true)}
>
<User className="h-4 w-4 mr-2" />
<div className="text-left">
<div className="font-medium">Edit Profile</div>
<div className="text-xs text-muted-foreground">Update your information</div>
<div className="text-xs text-muted-foreground">
Update your information
</div>
</div>
</Button>
<Button variant="outline" className="justify-start h-auto p-4" disabled>
<Button
variant="outline"
className="justify-start h-auto p-4"
onClick={() => setSecurityOpen(true)}
>
<Shield className="h-4 w-4 mr-2" />
<div className="text-left">
<div className="font-medium">Security Settings</div>
<div className="text-xs text-muted-foreground">Manage security options</div>
<div className="text-xs text-muted-foreground">
Manage security options
</div>
</div>
</Button>
<Button variant="outline" className="justify-start h-auto p-4" disabled>
<Button
variant="outline"
className="justify-start h-auto p-4"
onClick={() => setEmailPrefsOpen(true)}
>
<Mail className="h-4 w-4 mr-2" />
<div className="text-left">
<div className="font-medium">Email Preferences</div>
<div className="text-xs text-muted-foreground">Configure notifications</div>
<div className="text-xs text-muted-foreground">
Configure notifications
</div>
</div>
</Button>
</div>
<p className="text-xs text-muted-foreground mt-4">
Additional profile management features coming soon.
</p>
</CardContent>
</Card>
</div>
{/* Edit Profile Dialog */}
<Dialog open={editProfileOpen} onOpenChange={setEditProfileOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>
Update your profile information. Changes will be saved to your
account.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleEditProfileSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
defaultValue={user.name || ""}
placeholder="Enter your name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
defaultValue={user.email || ""}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Email cannot be changed for OAuth accounts
</p>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setEditProfileOpen(false)}
>
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Security Settings Dialog */}
<Dialog open={securityOpen} onOpenChange={setSecurityOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Security Settings</DialogTitle>
<DialogDescription>
Manage your account security and authentication options.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<Lock className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">Password</p>
<p className="text-sm text-muted-foreground">
{user.email?.includes("@gmail")
? "Managed by Google"
: "Set a password for your account"}
</p>
</div>
</div>
<Badge variant="outline">
{user.email?.includes("@gmail") ? "OAuth" : "Not Set"}
</Badge>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<Smartphone className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">Two-Factor Authentication</p>
<p className="text-sm text-muted-foreground">
Add an extra layer of security
</p>
</div>
</div>
<Button variant="outline" size="sm" disabled>
Coming Soon
</Button>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">Active Sessions</p>
<p className="text-sm text-muted-foreground">
Manage devices logged into your account
</p>
</div>
</div>
<Badge variant="default">1 Active</Badge>
</div>
</div>
<div className="flex justify-end pt-4">
<Button variant="outline" onClick={() => setSecurityOpen(false)}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
{/* Email Preferences Dialog */}
<Dialog open={emailPrefsOpen} onOpenChange={setEmailPrefsOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Email Preferences</DialogTitle>
<DialogDescription>
Configure your email notification settings.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Marketing Emails</p>
<p className="text-sm text-muted-foreground">
Product updates and announcements
</p>
</div>
<Badge variant="secondary">Coming Soon</Badge>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">Security Alerts</p>
<p className="text-sm text-muted-foreground">
Important security notifications
</p>
</div>
<Badge variant="default">Always On</Badge>
</div>
</div>
<div className="flex justify-end pt-4">
<Button variant="outline" onClick={() => setEmailPrefsOpen(false)}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
}

View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/dashboard/", "/profile/", "/chat/"],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

View File

@@ -0,0 +1,26 @@
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
{
url: `${baseUrl}/dashboard`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.8,
},
{
url: `${baseUrl}/chat`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.8,
},
];
}

View File

@@ -1,7 +1,7 @@
"use client";
import { signIn, useSession } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { signIn, useSession } from "@/lib/auth-client";
export function SignInButton() {
const { data: session, isPending } = useSession();

View File

@@ -1,8 +1,8 @@
"use client";
import { signOut, useSession } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { signOut, useSession } from "@/lib/auth-client";
export function SignOutButton() {
const { data: session, isPending } = useSession();

View File

@@ -1,7 +1,8 @@
"use client";
import { useSession, signOut } from "@/lib/auth-client";
import { SignInButton } from "./sign-in-button";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, LogOut } from "lucide-react";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
@@ -11,9 +12,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, LogOut } from "lucide-react";
import { useSession, signOut } from "@/lib/auth-client";
import { SignInButton } from "./sign-in-button";
export function UserProfile() {
const { data: session, isPending } = useSession();

View File

@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle2, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
type DiagnosticsResponse = {
timestamp: string;

View File

@@ -1,30 +1,46 @@
import Link from "next/link";
import { Bot } from "lucide-react";
import { UserProfile } from "@/components/auth/user-profile";
import { ModeToggle } from "./ui/mode-toggle";
import { Bot } from "lucide-react";
export function SiteHeader() {
return (
<header className="border-b">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold">
<Link
href="/"
className="flex items-center gap-2 text-primary hover:text-primary/80 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10">
<Bot className="h-5 w-5" />
</div>
<span className="bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
Starter Kit
</span>
</Link>
</h1>
<div className="flex items-center gap-4">
<UserProfile />
<ModeToggle />
</div>
</div>
</header>
<>
{/* Skip to main content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:text-foreground focus:border focus:rounded-md"
>
Skip to main content
</a>
<header className="border-b" role="banner">
<nav
className="container mx-auto px-4 py-4 flex justify-between items-center"
aria-label="Main navigation"
>
<h1 className="text-2xl font-bold">
<Link
href="/"
className="flex items-center gap-2 text-primary hover:text-primary/80 transition-colors"
aria-label="Starter Kit - Go to homepage"
>
<div
className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10"
aria-hidden="true"
>
<Bot className="h-5 w-5" />
</div>
<span className="bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
Starter Kit
</span>
</Link>
</h1>
<div className="flex items-center gap-4" role="group" aria-label="User actions">
<UserProfile />
<ModeToggle />
</div>
</nav>
</header>
</>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -10,7 +11,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Copy, Check } from "lucide-react";
const STARTER_PROMPT = `I'm working with an agentic coding boilerplate project that includes authentication, database integration, and AI capabilities. Here's what's already set up:
@@ -50,7 +50,7 @@ The only things to preserve are:
- **Build and development scripts** (keep all npm/pnpm scripts in package.json)
## Tech Stack
- Next.js 15 with App Router
- Next.js 16 with App Router
- TypeScript
- Tailwind CSS
- Better Auth for authentication

View File

@@ -2,7 +2,6 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({

View File

@@ -1,6 +1,5 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(

View File

@@ -1,7 +1,6 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(

View File

@@ -1,5 +1,4 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<

View File

@@ -3,7 +3,6 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({

View File

@@ -3,7 +3,6 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
@@ -85,7 +84,7 @@ function DropdownMenuItem({
function DropdownMenuCheckboxItem({
className,
children,
checked,
checked = false,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,9 +1,7 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,

View File

@@ -1,5 +1,4 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,42 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme } = useTheme()
const resolvedTheme: "system" | "light" | "dark" =
theme === "light" || theme === "dark" ? theme : "system"
return (
<Sonner
theme={resolvedTheme}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,21 @@
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface SpinnerProps {
className?: string;
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
};
export function Spinner({ className, size = "md" }: SpinnerProps) {
return (
<Loader2
className={cn("animate-spin text-muted-foreground", sizeClasses[size], className)}
/>
);
}

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,117 @@
import { z } from "zod";
/**
* Server-side environment variables schema.
* These variables are only available on the server.
*/
const serverEnvSchema = z.object({
// Database
POSTGRES_URL: z.string().url("Invalid database URL"),
// Authentication
BETTER_AUTH_SECRET: z
.string()
.min(32, "BETTER_AUTH_SECRET must be at least 32 characters"),
// OAuth
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// AI
OPENROUTER_API_KEY: z.string().optional(),
OPENROUTER_MODEL: z.string().default("openai/gpt-5-mini"),
// Storage
BLOB_READ_WRITE_TOKEN: z.string().optional(),
// App
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
});
/**
* Client-side environment variables schema.
* These variables are exposed to the browser via NEXT_PUBLIC_ prefix.
*/
const clientEnvSchema = z.object({
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
});
export type ServerEnv = z.infer<typeof serverEnvSchema>;
export type ClientEnv = z.infer<typeof clientEnvSchema>;
/**
* Validates and returns server-side environment variables.
* Throws an error if validation fails.
*/
export function getServerEnv(): ServerEnv {
const parsed = serverEnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error(
"Invalid server environment variables:",
parsed.error.flatten().fieldErrors
);
throw new Error("Invalid server environment variables");
}
return parsed.data;
}
/**
* Validates and returns client-side environment variables.
* Throws an error if validation fails.
*/
export function getClientEnv(): ClientEnv {
const parsed = clientEnvSchema.safeParse({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});
if (!parsed.success) {
console.error(
"Invalid client environment variables:",
parsed.error.flatten().fieldErrors
);
throw new Error("Invalid client environment variables");
}
return parsed.data;
}
/**
* Checks if required environment variables are set.
* Logs warnings for missing optional variables.
*/
export function checkEnv(): void {
const warnings: string[] = [];
// Check required variables
if (!process.env.POSTGRES_URL) {
throw new Error("POSTGRES_URL is required");
}
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is required");
}
// Check optional variables and warn
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
warnings.push("Google OAuth is not configured. Social login will be disabled.");
}
if (!process.env.OPENROUTER_API_KEY) {
warnings.push("OPENROUTER_API_KEY is not set. AI chat will not work.");
}
if (!process.env.BLOB_READ_WRITE_TOKEN) {
warnings.push("BLOB_READ_WRITE_TOKEN is not set. Using local storage for file uploads.");
}
// Log warnings in development
if (process.env.NODE_ENV === "development" && warnings.length > 0) {
console.warn("\n⚠ Environment warnings:");
warnings.forEach((w) => console.warn(` - ${w}`));
console.warn("");
}
}

View File

@@ -0,0 +1,157 @@
/**
* Simple in-memory rate limiter for API routes.
* For production, consider using a Redis-based solution.
*/
interface RateLimitConfig {
/** Number of requests allowed in the window */
limit: number;
/** Time window in milliseconds */
windowMs: number;
}
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
/**
* Default rate limit configurations for different endpoints.
*/
export const rateLimitConfigs = {
// AI chat endpoint - more restrictive
chat: {
limit: 20,
windowMs: 60 * 1000, // 20 requests per minute
},
// Auth endpoints
auth: {
limit: 10,
windowMs: 60 * 1000, // 10 requests per minute
},
// General API
api: {
limit: 100,
windowMs: 60 * 1000, // 100 requests per minute
},
} as const;
/**
* Check if a request should be rate limited.
*
* @param key - Unique identifier for the client (e.g., IP address, user ID)
* @param config - Rate limit configuration
* @returns Object with allowed status and remaining requests
*/
export function checkRateLimit(
key: string,
config: RateLimitConfig
): {
allowed: boolean;
remaining: number;
resetTime: number;
} {
const now = Date.now();
const entry = rateLimitStore.get(key);
// Clean up expired entries periodically
if (Math.random() < 0.01) {
cleanupExpiredEntries();
}
if (!entry || now > entry.resetTime) {
// Create new entry
const newEntry: RateLimitEntry = {
count: 1,
resetTime: now + config.windowMs,
};
rateLimitStore.set(key, newEntry);
return {
allowed: true,
remaining: config.limit - 1,
resetTime: newEntry.resetTime,
};
}
if (entry.count >= config.limit) {
return {
allowed: false,
remaining: 0,
resetTime: entry.resetTime,
};
}
// Increment count
entry.count++;
return {
allowed: true,
remaining: config.limit - entry.count,
resetTime: entry.resetTime,
};
}
/**
* Create a rate limit response with appropriate headers.
*/
export function createRateLimitResponse(resetTime: number): Response {
const retryAfter = Math.ceil((resetTime - Date.now()) / 1000);
return new Response(
JSON.stringify({
error: "Too Many Requests",
message: "Rate limit exceeded. Please try again later.",
retryAfter,
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfter),
"X-RateLimit-Reset": String(Math.ceil(resetTime / 1000)),
},
}
);
}
/**
* Get the client identifier from a request.
* Uses IP address or forwarded header.
*/
export function getClientIdentifier(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const ip = forwarded.split(",")[0];
if (ip) {
return ip.trim();
}
}
// Fallback to a hash of user-agent if no IP available
const userAgent = request.headers.get("user-agent") || "unknown";
return `ua-${hashString(userAgent)}`;
}
/**
* Simple string hash for fallback identifier.
*/
function hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
/**
* Clean up expired rate limit entries.
*/
function cleanupExpiredEntries(): void {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(key);
}
}
}

View File

@@ -1,52 +1,70 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const user = pgTable(
"user",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("user_email_idx").on(table.email)]
);
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("session_user_id_idx").on(table.userId),
index("session_token_idx").on(table.token),
]
);
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [
index("account_user_id_idx").on(table.userId),
index("account_provider_account_idx").on(table.providerId, table.accountId),
]
);
export const verification = pgTable("verification", {
id: text("id").primaryKey(),

View File

@@ -0,0 +1,48 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
/**
* Protected routes that require authentication.
* These are also configured in src/proxy.ts for optimistic redirects.
*/
export const protectedRoutes = ["/chat", "/dashboard", "/profile"];
/**
* Checks if the current request is authenticated.
* Should be called in Server Components for protected routes.
*
* @returns The session object if authenticated
* @throws Redirects to home page if not authenticated
*/
export async function requireAuth() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/");
}
return session;
}
/**
* Gets the current session without requiring authentication.
* Returns null if not authenticated.
*
* @returns The session object or null
*/
export async function getOptionalSession() {
return await auth.api.getSession({ headers: await headers() });
}
/**
* Checks if a given path is a protected route.
*
* @param path - The path to check
* @returns True if the path requires authentication
*/
export function isProtectedRoute(path: string): boolean {
return protectedRoutes.some(
(route) => path === route || path.startsWith(`${route}/`)
);
}

View File

@@ -1,7 +1,7 @@
import { put, del } from "@vercel/blob";
import { existsSync } from "fs";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
import { put, del } from "@vercel/blob";
/**
* Result from uploading a file to storage
@@ -11,14 +11,123 @@ export interface StorageResult {
pathname: string; // Path/key of the stored file
}
/**
* Storage configuration
*/
export interface StorageConfig {
/** Maximum file size in bytes (default: 5MB) */
maxSize?: number;
/** Allowed MIME types (default: images and documents) */
allowedTypes?: string[];
}
/**
* Default storage configuration
*/
const DEFAULT_CONFIG: Required<StorageConfig> = {
maxSize: 5 * 1024 * 1024, // 5MB
allowedTypes: [
// Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
// Documents
"application/pdf",
"text/plain",
"text/csv",
"application/json",
],
};
/**
* Allowed file extensions mapped from MIME types
*/
const ALLOWED_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".gif",
".webp",
".svg",
".pdf",
".txt",
".csv",
".json",
]);
/**
* Sanitize a filename by removing dangerous characters and path traversal attempts
*/
export function sanitizeFilename(filename: string): string {
// Remove path components (prevent directory traversal)
const basename = filename.split(/[/\\]/).pop() || filename;
// Remove or replace dangerous characters
const sanitized = basename
.replace(/[<>:"|?*\x00-\x1f]/g, "") // Remove dangerous chars
.replace(/\.{2,}/g, ".") // Collapse multiple dots
.replace(/^\.+/, "") // Remove leading dots
.trim();
// Ensure filename is not empty
if (!sanitized || sanitized.length === 0) {
throw new Error("Invalid filename");
}
// Limit filename length
if (sanitized.length > 255) {
const ext = sanitized.slice(sanitized.lastIndexOf("."));
const name = sanitized.slice(0, 255 - ext.length);
return name + ext;
}
return sanitized;
}
/**
* Validate file for upload
*/
export function validateFile(
buffer: Buffer,
filename: string,
config: StorageConfig = {}
): { valid: true } | { valid: false; error: string } {
const { maxSize } = { ...DEFAULT_CONFIG, ...config };
// Check file size
if (buffer.length > maxSize) {
return {
valid: false,
error: `File too large. Maximum size is ${Math.round(maxSize / 1024 / 1024)}MB`,
};
}
// Check file extension
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) {
return {
valid: false,
error: `File type not allowed. Allowed extensions: ${Array.from(ALLOWED_EXTENSIONS).join(", ")}`,
};
}
// Optionally check MIME type if provided
// Note: For full MIME type validation, consider using a library like 'file-type'
return { valid: true };
}
/**
* Uploads a file to storage (Vercel Blob or local filesystem)
*
*
* @param buffer - File contents as a Buffer
* @param filename - Name of the file (e.g., "image.png")
* @param folder - Optional folder/prefix (e.g., "avatars")
* @param config - Optional storage configuration
* @returns StorageResult with url and pathname
*
*
* @example
* ```ts
* const result = await upload(fileBuffer, "avatar.png", "avatars");
@@ -28,13 +137,23 @@ export interface StorageResult {
export async function upload(
buffer: Buffer,
filename: string,
folder?: string
folder?: string,
config?: StorageConfig
): Promise<StorageResult> {
// Sanitize filename
const sanitizedFilename = sanitizeFilename(filename);
// Validate file
const validation = validateFile(buffer, sanitizedFilename, config);
if (!validation.valid) {
throw new Error(validation.error);
}
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
if (hasVercelBlob) {
// Use Vercel Blob storage
const pathname = folder ? `${folder}/${filename}` : filename;
const pathname = folder ? `${folder}/${sanitizedFilename}` : sanitizedFilename;
const blob = await put(pathname, buffer, {
access: "public",
});
@@ -54,11 +173,11 @@ export async function upload(
}
// Write the file
const filepath = join(targetDir, filename);
const filepath = join(targetDir, sanitizedFilename);
await writeFile(filepath, buffer);
// Return local URL
const pathname = folder ? `${folder}/${filename}` : filename;
const pathname = folder ? `${folder}/${sanitizedFilename}` : sanitizedFilename;
const url = `/uploads/${pathname}`;
return {

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
/**
* Next.js 16 Proxy for auth protection.
* Uses cookie-based checks for fast, optimistic redirects.
*
* Note: This only checks for cookie existence, not validity.
* Full session validation should be done in each protected page/route.
*/
export async function proxy(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
// Optimistic redirect - cookie existence check only
// Full validation happens in page components via auth.api.getSession()
if (!sessionCookie) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard", "/chat", "/profile"], // Protected routes
};