diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e1a34d --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Better Auth Configuration +BETTER_AUTH_SECRET=your-secret-key +BETTER_AUTH_URL=http://localhost:3000 + +# Database +DATABASE_URL=postgresql://user:password@localhost:5432/dbname + +LOKI_URL=http://localhost:3100 + +# Next.js +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NODE_ENV=development diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 728cb10..081eda5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -4,9 +4,6 @@ on: push: branches: - main - pull_request: - branches: - - main jobs: build: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..a89290f --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,33 @@ +name: Test build and run test before merge + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Run tests + run: bun run build + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + + - name: Test completed + run: echo "Tests completed successfully." diff --git a/.gitignore b/.gitignore index 21f687c..abcfbc1 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ /.next/ /out/ +# github actions +.actrc +.secrets + # production /build @@ -31,7 +35,10 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env.development +.env.test +.env.production +.secrets # vercel .vercel diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx new file mode 100644 index 0000000..1aff1ef --- /dev/null +++ b/app/(dashboard)/analytics/page.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { ServerCogIcon, ServerCrashIcon } from "lucide-react"; + +interface SessionEvent { + timestamp: string; + eventType: "created" | "refreshed" | "terminated" | "unknown"; + sessionId: string; + userId: string; + expiresAt?: string; + message: string; +} + +interface SessionLogsResponse { + success: boolean; + count: number; + events: SessionEvent[]; + timeRange: { + hours: number; + startTime: string; + endTime: string; + }; +} + +const AnalyticsPage = () => { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [timeRange] = useState({ hours: 168 }); // 7 days + + useEffect(() => { + const fetchSessionLogs = async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch( + `/api/logs/sessions?hours=${timeRange.hours}` + ); + + if (!response.ok) { + throw new Error("Failed to fetch session logs"); + } + + const data: SessionLogsResponse = await response.json(); + + if (data.success) { + setEvents(data.events); + } else { + setError("Failed to fetch session logs from Loki"); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "An unexpected error occurred" + ); + } finally { + setLoading(false); + } + }; + + fetchSessionLogs(); + }, [timeRange]); + + const getEventBadgeColor = (eventType: SessionEvent["eventType"]) => { + switch (eventType) { + case "created": + return "bg-green-100 rounded-sm px-2 py-1 dark:bg-green-900 dark:text-white/70 text-green-800 hover:bg-green-200"; + case "refreshed": + return "bg-blue-100 rounded-sm px-2 py-1 dark:bg-blue-900 dark:text-white/70 text-blue-800 hover:bg-blue-200"; + case "terminated": + return "bg-red-100 rounded-sm px-2 py-1 dark:bg-red-900 dark:text-white/70 text-red-800 hover:bg-red-200"; + default: + return "bg-gray-100 rounded-sm px-2 py-1 dark:bg-gray-900 dark:text-white/70 text-gray-800 hover:bg-gray-200"; + } + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + }); + }; + + return ( +
+
+

Session Analytics

+

+ Monitor and analyze Better Auth session activity +

+
+ + + + Session Events + + All session-related events from the last 7 days + {events.length > 0 && ` (${events.length} events found)`} + + + + {loading ? ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ) : error ? ( + + + + {process.env.NODE_ENV === "production" ? ( + + ) : ( + + )} + + + {process.env.NODE_ENV === "production" + ? "Session Logs Unavailable" + : "Session Logs Not Loaded"} + + {error} + + + {/* For production, Loki, Grafana and Vector are not deployed in the production environment. */} + {/* For development. the loki, vector and grafana is not started yet. */} +

+ {process.env.NODE_ENV === "production" + ? "Contact support if you believe this is an error." + : "Start the observability stack using Docker Compose with the 'obs' profile."} +

+
+
+ ) : events.length === 0 ? ( +
+

No session events found

+

+ Session logs will appear here as users sign in and out +

+
+ ) : ( +
+ + + + + + + + + + {events.map((event, idx) => ( + + + + + + ))} + +
TimeEvent + Expires At +
+ {formatTimestamp(event.timestamp)} + + + {event.eventType.charAt(0).toUpperCase() + + event.eventType.slice(1)} + + + {event.expiresAt + ? formatTimestamp(event.expiresAt) + : "-"} +
+
+ )} +
+
+
+ ); +}; + +export default AnalyticsPage; diff --git a/app/api/logs/sessions/route.ts b/app/api/logs/sessions/route.ts new file mode 100644 index 0000000..1ce5cf7 --- /dev/null +++ b/app/api/logs/sessions/route.ts @@ -0,0 +1,129 @@ +import { config } from "@/lib"; +import { NextRequest, NextResponse } from "next/server"; + +interface LokiStream { + stream: Record; + values: [string, string][]; +} + +interface LokiResponse { + status: string; + data: { + resultType: string; + result: LokiStream[]; + }; +} + +interface SessionEvent { + timestamp: string; + eventType: "created" | "refreshed" | "terminated" | "unknown"; + sessionId: string; + userId: string; + expiresAt?: string; + message: string; +} + +/** + * Query Loki for session-related logs from Better Auth + * GET /api/logs/sessions?hours=168 (default: last 7 days = 168 hours) + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const hours = parseInt(searchParams.get("hours") || "168", 10); + const endTime = Math.floor(Date.now() * 1_000_000); // nanoseconds + const startTime = endTime - hours * 60 * 60 * 1_000_000_000; + + // Query Loki for session logs + const query = encodeURIComponent( + '{service="better-auth", component="session"} | json' + ); + + const lokiResponse = await fetch( + `${config.lokiURL}/loki/api/v1/query_range?query=${query}&start=${startTime}&end=${endTime}&limit=1000`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (!lokiResponse.ok) { + console.error("Loki query failed:", lokiResponse.statusText); + return NextResponse.json( + { error: "Failed to fetch logs from Loki" }, + { status: 500 } + ); + } + + const data: LokiResponse = await lokiResponse.json(); + + if (data.status !== "success") { + return NextResponse.json( + { error: "Loki query unsuccessful" }, + { status: 500 } + ); + } + + // Parse and structure the session events + const events: SessionEvent[] = []; + + for (const stream of data.data.result) { + for (const [timestamp, logLine] of stream.values) { + try { + const parsed = JSON.parse(logLine); + + console.log("Parsed log entry:", parsed); + + // Determine event type from message + let eventType: SessionEvent["eventType"] = "unknown"; + const message = parsed.message || ""; + + if (message.includes("created")) { + eventType = "created"; + } else if (message.includes("refreshed")) { + eventType = "refreshed"; + } else if (message.includes("terminated")) { + eventType = "terminated"; + } + + events.push({ + timestamp: new Date(parseInt(timestamp) / 1_000_000).toISOString(), + eventType, + sessionId: parsed.sessionId || "unknown", + userId: parsed.userId || "unknown", + expiresAt: parsed.expiresAt || parsed.timestamp, + message, + }); + } catch (e) { + // Skip parsing errors for individual logs + console.warn("Failed to parse log entry:", e); + } + } + } + + // Sort by timestamp descending (newest first) + events.sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + return NextResponse.json({ + success: true, + count: events.length, + events, + timeRange: { + hours, + startTime: new Date(startTime / 1_000_000).toISOString(), + endTime: new Date(endTime / 1_000_000).toISOString(), + }, + }); + } catch (error) { + console.error("Error fetching session logs:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx new file mode 100644 index 0000000..db49065 --- /dev/null +++ b/app/auth/layout.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from "react"; + +type Props = { + children: ReactNode; +}; + +const AuthLayout = ({ children }: Props) => { + return ( +
+
+ {/* You can add a logo or image here for the auth layout */} +
+

Welcome to Our App

+

+ Please sign in or create an account to continue. +

+
+
+ {children} +
+ ); +}; + +export default AuthLayout; diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..e430aab --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { signIn } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function SignInPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSignIn = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + const result = await signIn.email({ + email, + password, + }); + + if (result.error) { + const errorMessage = + result.error.message || + result.error.statusText || + JSON.stringify(result.error) || + "Sign in failed"; + setError(errorMessage); + } else { + router.refresh(); + router.push("/dashboard"); + } + + setLoading(false); + }; + + return ( +
+ + + + Sign in to your account + + +
+ {error && ( +
+
+ {error} +
+
+ )} +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ + + +
+ + Don't have an account? Sign up + +
+
+
+
+ ); +} diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx new file mode 100644 index 0000000..1f33706 --- /dev/null +++ b/app/auth/signup/page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { signUp } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function SignUpPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + const result = await signUp.email({ + email, + password, + name, + }); + + if (result.error) { + const errorMessage = + result.error.message || + result.error.statusText || + JSON.stringify(result.error) || + "Sign up failed"; + setError(errorMessage); + } else { + router.refresh(); + router.push("/dashboard"); + } + + setLoading(false); + }; + + return ( +
+ + + + Create your account + + + +
+ {error && ( +
+
+ {error} +
+
+ )} +
+
+ + setName(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ + +
+
+ + + +
+ + Already have an account? Sign in + +
+
+
+
+ ); +} diff --git a/app/sign-in/page.tsx b/app/sign-in/page.tsx deleted file mode 100644 index d396bd3..0000000 --- a/app/sign-in/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { signIn } from "@/lib/auth-client"; - -export default function SignInPage() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const handleSignIn = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setLoading(true); - - const result = await signIn.email({ - email, - password, - }); - - if (result.error) { - const errorMessage = - result.error.message || - result.error.statusText || - JSON.stringify(result.error) || - "Sign in failed"; - setError(errorMessage); - console.error("Sign-in error:", result.error); - } else { - console.log("Sign-in successful:", result.data); - router.refresh(); - router.push("/"); - } - - setLoading(false); - }; - - return ( -
-
-
-

- Sign in to your account -

-
-
- {error && ( -
-
{error}
-
- )} -
-
- - setEmail(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
-
- -
- -
- -
- - Don't have an account? Sign up - -
-
-
-
- ); -} diff --git a/app/sign-up/page.tsx b/app/sign-up/page.tsx deleted file mode 100644 index 67cb771..0000000 --- a/app/sign-up/page.tsx +++ /dev/null @@ -1,131 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { signUp } from "@/lib/auth-client"; - -export default function SignUpPage() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [name, setName] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const handleSignUp = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setLoading(true); - - const result = await signUp.email({ - email, - password, - name, - }); - - if (result.error) { - const errorMessage = - result.error.message || - result.error.statusText || - JSON.stringify(result.error) || - "Sign up failed"; - setError(errorMessage); - console.error("Sign-up error:", result.error); - } else { - console.log("Sign-up successful:", result.data); - router.refresh(); - router.push("/"); - } - - setLoading(false); - }; - - return ( -
-
-
-

- Create your account -

-
-
- {error && ( -
-
{error}
-
- )} -
-
- - setName(e.target.value)} - /> -
-
- - setEmail(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
-
- -
- -
- -
- - Already have an account? Sign in - -
-
-
-
- ); -} diff --git a/auth.ts b/auth.ts index c441da0..19d26fb 100644 --- a/auth.ts +++ b/auth.ts @@ -1,8 +1,15 @@ -import { betterAuth } from "better-auth"; +import { APIError, betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "./db/client"; import * as schema from "./db/schema"; import { nextCookies } from "better-auth/next-js"; +import { createAuthMiddleware } from "better-auth/api"; +import { createBetterAuthLokiLogger, createLokiLogger } from "./lib/loki"; + +// Create dedicated loggers for different auth components +const authLogger = createLokiLogger("better-auth"); +const sessionLogger = createLokiLogger("better-auth", { component: "session" }); +const userLogger = createLokiLogger("better-auth", { component: "user" }); export const auth = betterAuth({ database: drizzleAdapter(db, { @@ -23,7 +30,164 @@ export const auth = betterAuth({ maxAge: 5 * 60, // 5 minutes }, }, + // Logger configured to push logs to Grafana Loki in development mode + logger: createBetterAuthLokiLogger(), plugins: [nextCookies()], secret: process.env.BETTER_AUTH_SECRET as string, baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000", + + // Database hooks for tracking user and session lifecycle + databaseHooks: { + // User lifecycle hooks + user: { + create: { + after: async (user) => { + userLogger.info("New user created", { + userId: user.id, + email: user.email, + createdAt: new Date().toISOString(), + }); + }, + }, + update: { + after: async (user) => { + userLogger.info("User updated", { + userId: user.id, + email: user.email, + updatedAt: new Date().toISOString(), + }); + }, + }, + }, + // Session lifecycle hooks + session: { + create: { + after: async (session) => { + sessionLogger.info("Session created by database hook", { + sessionId: session.id, + userId: session.userId, + expiresAt: session.expiresAt, + createdAt: new Date().toISOString(), + }); + }, + }, + update: { + after: async (session) => { + sessionLogger.debug("Session refreshed by database hook", { + sessionId: session.id, + userId: session.userId, + expiresAt: session.expiresAt, + refreshedAt: new Date().toISOString(), + }); + }, + }, + delete: { + after: async (session) => { + sessionLogger.info("Session terminated by database hook", { + sessionId: session.id, + userId: session.userId, + terminatedAt: new Date().toISOString(), + }); + }, + }, + }, + }, + + hooks: { + before: createAuthMiddleware(async (ctx) => { + const { path, method, headers } = ctx; + const userAgent = headers?.get?.("user-agent") || "unknown"; + const ip = + headers?.get?.("x-forwarded-for") || + headers?.get?.("x-real-ip") || + "unknown"; + + // Log authentication attempts + if (path.includes("/sign-in")) { + authLogger.info("Sign-in attempt by Before:hook", { + path, + method, + userAgent, + ip, + }); + } else if (path.includes("/sign-up")) { + authLogger.info("Sign-up attempt by Before:hook", { + path, + method, + userAgent, + ip, + }); + } else if (path.includes("/sign-out")) { + // authLogger.info("Sign out attempt", { + // path, + // userAgent, + // method, + // ip, + // }); + sessionLogger.info("Session terminated", { + path, + method, + userAgent, + ip, + }); + ctx.redirect("/"); + } else if (path.includes("/session")) { + authLogger.debug("Session validation request", { + path, + method, + userAgent, + ip, + }); + } else if ( + path.includes("/forgot-password") || + path.includes("/reset-password") + ) { + authLogger.info("Password reset attempt", { path, method, ip }); + } + }), + after: createAuthMiddleware(async (ctx) => { + const { path, context } = ctx; + const response = context.returned; + + // Log authentication results + if (path.includes("/sign-in") || path.includes("/sign-up")) { + if (response instanceof Response && response.ok) { + authLogger.info("Authentication successful by After:hook", { + path, + status: response.status, + }); + } else if (response instanceof Response && !response.ok) { + authLogger.warn("Authentication failed by After:hook", { + path, + status: response.status, + }); + } + } + + // Log sign-out results + if (path.includes("/sign-out")) { + if (response instanceof Response && response.ok) { + authLogger.info("Sign-out successful", { + path, + status: response.status, + }); + } + } + + // Log session validation results + if (path.includes("/session")) { + if (response instanceof Response && response.ok) { + authLogger.debug("Session valid", { + path, + status: response.status, + }); + } else if (response instanceof Response && !response.ok) { + authLogger.debug("Session invalid or expired", { + path, + status: response.status, + }); + } + } + }), + }, }); diff --git a/bun.lock b/bun.lock index fd005bc..b874446 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "lucide-react": "^0.561.0", "next": "16.0.7", "next-themes": "^0.4.6", + "pg": "^8.16.3", "react": "19.2.0", "react-dom": "19.2.0", "recharts": "2.15.4", @@ -44,6 +45,7 @@ "@biomejs/biome": "2.2.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0", @@ -544,12 +546,22 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], + + "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], + + "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="], + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="], + "pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -608,6 +620,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 120b3e5..e294ec7 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { ArrowUpCircleIcon } from "lucide-react"; +import { LayoutDashboard } from "lucide-react"; // import { NavDocuments } from "@/components/nav-documents"; import { NavMain } from "@/components/nav-main"; @@ -15,6 +15,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarSeparator, } from "@/components/ui/sidebar"; import { data } from "@/constant/sidebar-items"; import Link from "next/link"; @@ -27,23 +28,27 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - - - Acme Inc. + + + Onyx. + {/* */} - + ); diff --git a/components/nav-main.tsx b/components/nav-main.tsx index d9fc9dd..98dba64 100644 --- a/components/nav-main.tsx +++ b/components/nav-main.tsx @@ -10,8 +10,9 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; +import { cn } from "@/lib/utils"; export function NavMain({ items, @@ -19,38 +20,26 @@ export function NavMain({ items: { title: string; url: string; - icon?: LucideIcon; + icon: LucideIcon; }[]; }) { const router = useRouter(); + const pathname = usePathname(); return ( - - - - - - Quick Create - - - - + {items.map((item) => { const Icon = item.icon; return ( - - + + router.push(item.url)}> {Icon && } {item.title} diff --git a/components/nav-user.tsx b/components/nav-user.tsx index 2eb61f9..5c01735 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import { BellIcon, @@ -6,13 +6,9 @@ import { LogOutIcon, MoreVerticalIcon, UserCircleIcon, -} from "lucide-react" +} from "lucide-react"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@/components/ui/avatar" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, @@ -21,24 +17,29 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +} from "@/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, -} from "@/components/ui/sidebar" +} from "@/components/ui/sidebar"; +import { authClient, useSession } from "@/lib/auth-client"; +import { useRouter } from "next/navigation"; + +export function NavUser() { + const { data: session } = useSession(); + const router = useRouter(); + const user = { + name: session?.user?.name || "User Name", + email: session?.user?.email || "user@example.com", + avatar: + session?.user?.image || + `https://www.gravatar.com/avatar/${session?.user?.name}}?d=identicon` || + "https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y", + }; -export function NavUser({ - user, -}: { - user: { - name: string - email: string - avatar: string - } -}) { - const { isMobile } = useSidebar() + const { isMobile } = useSidebar(); return ( @@ -98,7 +99,12 @@ export function NavUser({ - + { + authClient.signOut(); + router.push("/auth/login"); + }} + > Log out @@ -106,5 +112,5 @@ export function NavUser({ - ) + ); } diff --git a/constant/sidebar-items.ts b/constant/sidebar-items.ts index ce0e813..bcf8e4b 100644 --- a/constant/sidebar-items.ts +++ b/constant/sidebar-items.ts @@ -1,5 +1,4 @@ import { - LayoutDashboardIcon, BarChartIcon, FolderIcon, CameraIcon, @@ -11,6 +10,7 @@ import { DatabaseIcon, ClipboardListIcon, FileIcon, + Monitor, } from "lucide-react"; export const data = { @@ -23,11 +23,11 @@ export const data = { { title: "Dashboard", url: "/dashboard", - icon: LayoutDashboardIcon, + icon: Monitor, }, { title: "Analytics", - url: "#", + url: "/analytics", icon: BarChartIcon, }, { diff --git a/db/client.ts b/db/client.ts index fabb6c0..56c5877 100644 --- a/db/client.ts +++ b/db/client.ts @@ -1,6 +1,23 @@ -import { drizzle } from "drizzle-orm/neon-http"; +import { drizzle as drizzleNeon } from "drizzle-orm/neon-http"; +import { drizzle as drizzleNode } from "drizzle-orm/node-postgres"; import { neon } from "@neondatabase/serverless"; +import { Pool } from "pg"; import * as schema from "./schema"; +import { config } from "@/lib"; -const sql = neon(process.env.DATABASE_URL!); -export const db = drizzle({ client: sql, schema }); +const isProduction = process.env.NODE_ENV === "production"; + +function createDb() { + if (isProduction) { + // Use Neon for production + const sql = neon(config.database!); + return drizzleNeon({ client: sql, schema }); + } + // Use node-postgres for local development + const pool = new Pool({ + connectionString: config.database!, + }); + return drizzleNode({ client: pool, schema }); +} + +export const db = createDb(); diff --git a/drizzle.config.ts b/drizzle.config.ts index a462b80..4d7f644 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from "drizzle-kit"; +import { config } from "./lib"; export default defineConfig({ dialect: "postgresql", schema: "./db/schema.ts", out: "./db/migrations", dbCredentials: { - url: process.env.DATABASE_URL!, + url: config.database, }, }); diff --git a/lib/auth-client.ts b/lib/auth-client.ts index 772f504..b601bee 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -1,11 +1,10 @@ import { createAuthClient } from "better-auth/react"; +import { config } from "./config"; export const authClient = createAuthClient({ - baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000", + baseURL: config.baseURL!, fetchOptions: { - onError(e) { - console.error("Auth error:", e.error); - }, + credentials: "include", }, }); diff --git a/lib/config.ts b/lib/config.ts index ad58c30..aaefa0a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -9,8 +9,11 @@ export const dummyUser: NewUser = { }; export const config = { - NODE_ENV: process.env.NODE_ENV, - betterAuthSecret: process.env.BETTER_AUTH_SECRET, - database: process.env.DATABASE_URL, + betterAuthSecret: process.env.BETTER_AUTH_SECRET || "your-secret-key", + database: + process.env.DATABASE_URL || + "postgresql://user:password@localhost:5432/dbname", dummyUser, + lokiURL: process.env.LOKI_URL || "http://localhost:3100", + baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000", }; diff --git a/lib/loki.ts b/lib/loki.ts index 4815620..9de277b 100644 --- a/lib/loki.ts +++ b/lib/loki.ts @@ -17,6 +17,8 @@ * ``` */ +import { config } from "./config"; + type LogLevel = "debug" | "info" | "warn" | "error"; interface LokiStream { @@ -33,8 +35,7 @@ interface LogMetadata { } // Configuration -const LOKI_URL = process.env.LOKI_URL || "http://localhost:3100"; -const LOKI_PUSH_ENDPOINT = `${LOKI_URL}/loki/api/v1/push`; +const LOKI_PUSH_ENDPOINT = `${config.lokiURL}/loki/api/v1/push`; const IS_DEV = process.env.NODE_ENV === "development"; /** diff --git a/package.json b/package.json index ee1297d..af8b046 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "docker:dev": "docker compose -f docker-compose.dev.yaml", "docker:obs": "docker compose -f docker-compose.dev.yaml --profile obs", "with-dev-env": "dotenv -e .env.development --", + "with-prod-env": "dotenv -e .env.production --", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio" @@ -43,6 +44,7 @@ "lucide-react": "^0.561.0", "next": "16.0.7", "next-themes": "^0.4.6", + "pg": "^8.16.3", "react": "19.2.0", "react-dom": "19.2.0", "recharts": "2.15.4", @@ -55,6 +57,7 @@ "@biomejs/biome": "2.2.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", "babel-plugin-react-compiler": "1.0.0",