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:
@@ -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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
46
create-agentic-app/template/src/app/chat/error.tsx
Normal file
46
create-agentic-app/template/src/app/chat/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
create-agentic-app/template/src/app/chat/loading.tsx
Normal file
42
create-agentic-app/template/src/app/chat/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
63
create-agentic-app/template/src/app/dashboard/loading.tsx
Normal file
63
create-agentic-app/template/src/app/dashboard/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
44
create-agentic-app/template/src/app/error.tsx
Normal file
44
create-agentic-app/template/src/app/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
21
create-agentic-app/template/src/app/manifest.ts
Normal file
21
create-agentic-app/template/src/app/manifest.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
28
create-agentic-app/template/src/app/not-found.tsx
Normal file
28
create-agentic-app/template/src/app/not-found.tsx
Normal 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're looking for doesn'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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
16
create-agentic-app/template/src/app/robots.ts
Normal file
16
create-agentic-app/template/src/app/robots.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
26
create-agentic-app/template/src/app/sitemap.ts
Normal file
26
create-agentic-app/template/src/app/sitemap.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
20
create-agentic-app/template/src/components/ui/input.tsx
Normal file
20
create-agentic-app/template/src/components/ui/input.tsx
Normal 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 }
|
||||
23
create-agentic-app/template/src/components/ui/label.tsx
Normal file
23
create-agentic-app/template/src/components/ui/label.tsx
Normal 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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
|
||||
13
create-agentic-app/template/src/components/ui/skeleton.tsx
Normal file
13
create-agentic-app/template/src/components/ui/skeleton.tsx
Normal 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 }
|
||||
42
create-agentic-app/template/src/components/ui/sonner.tsx
Normal file
42
create-agentic-app/template/src/components/ui/sonner.tsx
Normal 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 }
|
||||
21
create-agentic-app/template/src/components/ui/spinner.tsx
Normal file
21
create-agentic-app/template/src/components/ui/spinner.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
create-agentic-app/template/src/components/ui/textarea.tsx
Normal file
17
create-agentic-app/template/src/components/ui/textarea.tsx
Normal 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 }
|
||||
117
create-agentic-app/template/src/lib/env.ts
Normal file
117
create-agentic-app/template/src/lib/env.ts
Normal 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("");
|
||||
}
|
||||
}
|
||||
157
create-agentic-app/template/src/lib/rate-limit.ts
Normal file
157
create-agentic-app/template/src/lib/rate-limit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
48
create-agentic-app/template/src/lib/session.ts
Normal file
48
create-agentic-app/template/src/lib/session.ts
Normal 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}/`)
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
25
create-agentic-app/template/src/proxy.ts
Normal file
25
create-agentic-app/template/src/proxy.ts
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user