diff --git a/.env.example b/.env.example index 1d9132c..af836e4 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,13 @@ NPM_VERSION=latest # Local docker host port (used by docker-compose.override.yml) APP_PORT=3000 +# Better Auth / PostgreSQL +DATABASE_URL=postgres://postgres:postgres@localhost:5432/fiscal_clone +BETTER_AUTH_SECRET=replace-with-a-long-random-secret +BETTER_AUTH_BASE_URL=http://localhost:3000 +BETTER_AUTH_ADMIN_USER_IDS= +BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 + # OpenClaw / ZeroClaw (OpenAI-compatible) OPENCLAW_BASE_URL=http://localhost:4000 OPENCLAW_API_KEY=replace-with-your-agent-key diff --git a/README.md b/README.md index 30299ae..d1392af 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration. - Next.js 16 App Router - Turbopack for `dev` and `build` +- Better Auth (email/password, magic link, admin, organization plugins) +- PostgreSQL adapter for Better Auth - Internal API routes (`app/api/*`) - Durable local task engine and JSON data store - OpenClaw/ZeroClaw analysis via OpenAI-compatible chat endpoint @@ -19,6 +21,9 @@ npm run dev Open [http://localhost:3000](http://localhost:3000). +Better Auth requires PostgreSQL. Set `DATABASE_URL`, `BETTER_AUTH_SECRET`, and `BETTER_AUTH_BASE_URL` in `.env.local`. +Auth tables are migrated automatically on first authenticated request. + ## Production build ```bash @@ -34,6 +39,7 @@ docker compose up --build -d ``` For local Docker, host port mapping comes from `docker-compose.override.yml` (default `http://localhost:3000`, configurable via `APP_PORT`). +The local override also starts PostgreSQL and wires `DATABASE_URL` to `postgres://postgres:postgres@postgres:5432/fiscal_clone`. For Coolify/remote Docker Compose, only container port `3000` is exposed internally (no fixed host port bind), avoiding host port collisions. Runtime data persists in the `app_data` volume (`/app/data` in container). Docker builds install the npm version from `NPM_VERSION` (default `latest`). @@ -45,6 +51,11 @@ Use root `.env` or root `.env.local`: ```env # leave blank for same-origin API NEXT_PUBLIC_API_URL= +DATABASE_URL=postgres://postgres:postgres@localhost:5432/fiscal_clone +BETTER_AUTH_SECRET=replace-with-a-long-random-secret +BETTER_AUTH_BASE_URL=http://localhost:3000 +BETTER_AUTH_ADMIN_USER_IDS= +BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 OPENCLAW_BASE_URL=http://localhost:4000 OPENCLAW_API_KEY=your_key @@ -56,6 +67,7 @@ If OpenClaw is unset, the app uses local fallback analysis so task workflows sti ## API surface +- `GET|POST|PATCH|PUT|DELETE /api/auth/*` (Better Auth handler) - `GET /api/health` - `GET /api/me` - `GET|POST /api/watchlist` diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..257040b --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,9 @@ +import { toNextJsHandler } from 'better-auth/next-js'; +import { ensureAuthSchema } from '@/lib/auth'; + +const authHandler = toNextJsHandler(async (request: Request) => { + const auth = await ensureAuthSchema(); + return auth.handler(request); +}); + +export const { GET, POST, PATCH, PUT, DELETE } = authHandler; diff --git a/app/api/filings/[accessionNumber]/analyze/route.ts b/app/api/filings/[accessionNumber]/analyze/route.ts index 66fe27e..1432b3c 100644 --- a/app/api/filings/[accessionNumber]/analyze/route.ts +++ b/app/api/filings/[accessionNumber]/analyze/route.ts @@ -1,4 +1,5 @@ import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { enqueueTask } from '@/lib/server/tasks'; type Context = { @@ -6,6 +7,11 @@ type Context = { }; export async function POST(_request: Request, context: Context) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + try { const { accessionNumber } = await context.params; @@ -14,6 +20,7 @@ export async function POST(_request: Request, context: Context) { } const task = await enqueueTask({ + userId: session.user.id, taskType: 'analyze_filing', payload: { accessionNumber: accessionNumber.trim() }, priority: 65 diff --git a/app/api/filings/route.ts b/app/api/filings/route.ts index 1de425c..5722a0e 100644 --- a/app/api/filings/route.ts +++ b/app/api/filings/route.ts @@ -1,6 +1,12 @@ +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { getStoreSnapshot } from '@/lib/server/store'; export async function GET(request: Request) { + const { response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + const url = new URL(request.url); const tickerFilter = url.searchParams.get('ticker')?.trim().toUpperCase(); const limitValue = Number(url.searchParams.get('limit') ?? 50); diff --git a/app/api/filings/sync/route.ts b/app/api/filings/sync/route.ts index 503082b..1f071fe 100644 --- a/app/api/filings/sync/route.ts +++ b/app/api/filings/sync/route.ts @@ -1,7 +1,13 @@ import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { enqueueTask } from '@/lib/server/tasks'; export async function POST(request: Request) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + try { const payload = await request.json() as { ticker?: string; @@ -13,6 +19,7 @@ export async function POST(request: Request) { } const task = await enqueueTask({ + userId: session.user.id, taskType: 'sync_filings', payload: { ticker: payload.ticker.trim().toUpperCase(), diff --git a/app/api/me/route.ts b/app/api/me/route.ts index 9aa93c1..a43c5f4 100644 --- a/app/api/me/route.ts +++ b/app/api/me/route.ts @@ -1,10 +1,17 @@ +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; + export async function GET() { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + return Response.json({ user: { - id: 1, - email: 'operator@local.fiscal', - name: 'Local Operator', - image: null + id: session.user.id, + email: session.user.email, + name: session.user.name, + image: session.user.image } }); } diff --git a/app/api/portfolio/holdings/[id]/route.ts b/app/api/portfolio/holdings/[id]/route.ts index 151fe7e..8e4c963 100644 --- a/app/api/portfolio/holdings/[id]/route.ts +++ b/app/api/portfolio/holdings/[id]/route.ts @@ -1,4 +1,5 @@ import { jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { recalculateHolding } from '@/lib/server/portfolio'; import { withStore } from '@/lib/server/store'; @@ -16,6 +17,12 @@ function asPositiveNumber(value: unknown) { } export async function PATCH(request: Request, context: Context) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; const { id } = await context.params; const numericId = Number(id); @@ -33,7 +40,7 @@ export async function PATCH(request: Request, context: Context) { let updated: unknown = null; await withStore((store) => { - const index = store.holdings.findIndex((entry) => entry.id === numericId); + const index = store.holdings.findIndex((entry) => entry.id === numericId && entry.user_id === userId); if (index < 0) { return; } @@ -66,6 +73,12 @@ export async function PATCH(request: Request, context: Context) { } export async function DELETE(_request: Request, context: Context) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; const { id } = await context.params; const numericId = Number(id); @@ -76,7 +89,7 @@ export async function DELETE(_request: Request, context: Context) { let removed = false; await withStore((store) => { - const next = store.holdings.filter((holding) => holding.id !== numericId); + const next = store.holdings.filter((holding) => !(holding.id === numericId && holding.user_id === userId)); removed = next.length !== store.holdings.length; store.holdings = next; }); diff --git a/app/api/portfolio/holdings/route.ts b/app/api/portfolio/holdings/route.ts index 324d515..c15d8b7 100644 --- a/app/api/portfolio/holdings/route.ts +++ b/app/api/portfolio/holdings/route.ts @@ -1,5 +1,6 @@ import type { Holding } from '@/lib/types'; import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { recalculateHolding } from '@/lib/server/portfolio'; import { getStoreSnapshot, withStore } from '@/lib/server/store'; @@ -13,8 +14,15 @@ function asPositiveNumber(value: unknown) { } export async function GET() { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; const snapshot = await getStoreSnapshot(); const holdings = snapshot.holdings + .filter((holding) => holding.user_id === userId) .slice() .sort((a, b) => Number(b.market_value) - Number(a.market_value)); @@ -22,6 +30,13 @@ export async function GET() { } export async function POST(request: Request) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; + try { const payload = await request.json() as { ticker?: string; @@ -50,7 +65,7 @@ export async function POST(request: Request) { let holding: Holding | null = null; await withStore((store) => { - const existingIndex = store.holdings.findIndex((entry) => entry.ticker === ticker); + const existingIndex = store.holdings.findIndex((entry) => entry.user_id === userId && entry.ticker === ticker); const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost; if (existingIndex >= 0) { @@ -73,7 +88,7 @@ export async function POST(request: Request) { store.counters.holdings += 1; const created = recalculateHolding({ id: store.counters.holdings, - user_id: 1, + user_id: userId, ticker, shares: shares.toFixed(6), avg_cost: avgCost.toFixed(6), diff --git a/app/api/portfolio/insights/generate/route.ts b/app/api/portfolio/insights/generate/route.ts index 904239b..81f7285 100644 --- a/app/api/portfolio/insights/generate/route.ts +++ b/app/api/portfolio/insights/generate/route.ts @@ -1,9 +1,16 @@ import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { enqueueTask } from '@/lib/server/tasks'; export async function POST() { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + try { const task = await enqueueTask({ + userId: session.user.id, taskType: 'portfolio_insights', payload: {}, priority: 70 diff --git a/app/api/portfolio/insights/latest/route.ts b/app/api/portfolio/insights/latest/route.ts index 98c3120..68d5858 100644 --- a/app/api/portfolio/insights/latest/route.ts +++ b/app/api/portfolio/insights/latest/route.ts @@ -1,8 +1,16 @@ +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { getStoreSnapshot } from '@/lib/server/store'; export async function GET() { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; const snapshot = await getStoreSnapshot(); const insight = snapshot.insights + .filter((entry) => entry.user_id === userId) .slice() .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0] ?? null; diff --git a/app/api/portfolio/refresh-prices/route.ts b/app/api/portfolio/refresh-prices/route.ts index 734a5c5..a634b42 100644 --- a/app/api/portfolio/refresh-prices/route.ts +++ b/app/api/portfolio/refresh-prices/route.ts @@ -1,9 +1,16 @@ import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { enqueueTask } from '@/lib/server/tasks'; export async function POST() { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + try { const task = await enqueueTask({ + userId: session.user.id, taskType: 'refresh_prices', payload: {}, priority: 80 diff --git a/app/api/portfolio/summary/route.ts b/app/api/portfolio/summary/route.ts index 1e3fec4..d3c4f32 100644 --- a/app/api/portfolio/summary/route.ts +++ b/app/api/portfolio/summary/route.ts @@ -1,8 +1,15 @@ import { buildPortfolioSummary } from '@/lib/server/portfolio'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { getStoreSnapshot } from '@/lib/server/store'; export async function GET() { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; const snapshot = await getStoreSnapshot(); - const summary = buildPortfolioSummary(snapshot.holdings); + const summary = buildPortfolioSummary(snapshot.holdings.filter((holding) => holding.user_id === userId)); return Response.json({ summary }); } diff --git a/app/api/tasks/[taskId]/route.ts b/app/api/tasks/[taskId]/route.ts index 0a2bf2f..c11dfca 100644 --- a/app/api/tasks/[taskId]/route.ts +++ b/app/api/tasks/[taskId]/route.ts @@ -1,4 +1,5 @@ import { jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { getTaskById } from '@/lib/server/tasks'; type Context = { @@ -6,8 +7,13 @@ type Context = { }; export async function GET(_request: Request, context: Context) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + const { taskId } = await context.params; - const task = await getTaskById(taskId); + const task = await getTaskById(taskId, session.user.id); if (!task) { return jsonError('Task not found', 404); diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts index e394cb7..f4e8c82 100644 --- a/app/api/tasks/route.ts +++ b/app/api/tasks/route.ts @@ -1,9 +1,15 @@ import type { TaskStatus } from '@/lib/types'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { listRecentTasks } from '@/lib/server/tasks'; const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed']; export async function GET(request: Request) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + const url = new URL(request.url); const limitValue = Number(url.searchParams.get('limit') ?? 20); const limit = Number.isFinite(limitValue) @@ -15,6 +21,6 @@ export async function GET(request: Request) { return ALLOWED_STATUSES.includes(status as TaskStatus); }); - const tasks = await listRecentTasks(limit, statuses.length > 0 ? statuses : undefined); + const tasks = await listRecentTasks(session.user.id, limit, statuses.length > 0 ? statuses : undefined); return Response.json({ tasks }); } diff --git a/app/api/watchlist/[id]/route.ts b/app/api/watchlist/[id]/route.ts index c95d44a..462bf9b 100644 --- a/app/api/watchlist/[id]/route.ts +++ b/app/api/watchlist/[id]/route.ts @@ -1,4 +1,5 @@ import { jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { withStore } from '@/lib/server/store'; type Context = { @@ -6,6 +7,12 @@ type Context = { }; export async function DELETE(_request: Request, context: Context) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; const { id } = await context.params; const numericId = Number(id); @@ -16,7 +23,7 @@ export async function DELETE(_request: Request, context: Context) { let removed = false; await withStore((store) => { - const next = store.watchlist.filter((item) => item.id !== numericId); + const next = store.watchlist.filter((item) => !(item.id === numericId && item.user_id === userId)); removed = next.length !== store.watchlist.length; store.watchlist = next; }); diff --git a/app/api/watchlist/route.ts b/app/api/watchlist/route.ts index 205d901..f1905a8 100644 --- a/app/api/watchlist/route.ts +++ b/app/api/watchlist/route.ts @@ -1,5 +1,6 @@ import type { WatchlistItem } from '@/lib/types'; import { asErrorMessage, jsonError } from '@/lib/server/http'; +import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { getStoreSnapshot, withStore } from '@/lib/server/store'; function nowIso() { @@ -7,8 +8,15 @@ function nowIso() { } export async function GET() { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; const snapshot = await getStoreSnapshot(); const items = snapshot.watchlist + .filter((item) => item.user_id === userId) .slice() .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); @@ -16,6 +24,13 @@ export async function GET() { } export async function POST(request: Request) { + const { session, response } = await requireAuthenticatedSession(); + if (response) { + return response; + } + + const userId = session.user.id; + try { const payload = await request.json() as { ticker?: string; @@ -35,7 +50,7 @@ export async function POST(request: Request) { await withStore((store) => { const ticker = payload.ticker!.trim().toUpperCase(); - const existingIndex = store.watchlist.findIndex((entry) => entry.ticker === ticker); + const existingIndex = store.watchlist.findIndex((entry) => entry.user_id === userId && entry.ticker === ticker); if (existingIndex >= 0) { const existing = store.watchlist[existingIndex]; @@ -53,7 +68,7 @@ export async function POST(request: Request) { store.counters.watchlist += 1; const created: WatchlistItem = { id: store.counters.watchlist, - user_id: 1, + user_id: userId, ticker, company_name: payload.companyName!.trim(), sector: payload.sector?.trim() || null, diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 6034398..50a712b 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -1,32 +1,151 @@ 'use client'; import Link from 'next/link'; +import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { AuthShell } from '@/components/auth/auth-shell'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { authClient } from '@/lib/auth-client'; + +function sanitizeNextPath(value: string | null) { + if (!value || !value.startsWith('/')) { + return '/'; + } + + return value; +} export default function SignInPage() { + return ( + Loading sign in...}> + + + ); +} + +function SignInPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]); + const { data: rawSession, isPending } = authClient.useSession(); + const session = (rawSession ?? null) as { user?: { id?: string } } | null; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [busyAction, setBusyAction] = useState<'password' | 'magic' | null>(null); + + useEffect(() => { + if (!isPending && session?.user?.id) { + router.replace(nextPath); + } + }, [isPending, nextPath, router, session]); + + const signInWithPassword = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setMessage(null); + setBusyAction('password'); + + const { error: signInError } = await authClient.signIn.email({ + email: email.trim(), + password, + callbackURL: nextPath + }); + + setBusyAction(null); + + if (signInError) { + setError(signInError.message || 'Sign in failed.'); + return; + } + + router.replace(nextPath); + }; + + const signInWithMagicLink = async () => { + const targetEmail = email.trim(); + if (!targetEmail) { + setError('Email is required for magic link sign in.'); + return; + } + + setError(null); + setMessage(null); + setBusyAction('magic'); + + const { error: magicError } = await authClient.signIn.magicLink({ + email: targetEmail, + callbackURL: nextPath + }); + + setBusyAction(null); + + if (magicError) { + setError(magicError.message || 'Unable to send magic link.'); + return; + } + + setMessage('Magic link sent. Check your inbox and open the link on this device.'); + }; + return ( - Need multi-user auth later?{' '} - - Open command center + Need an account?{' '} + + Create one )} > -

- Continue directly into the fiscal terminal. API routes are same-origin and task execution is fully local with OpenClaw support. -

+
+
+ + setEmail(event.target.value)} + required + /> +
- - - +
+ +
+ +
); } diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx index 469ae3b..6800780 100644 --- a/app/auth/signup/page.tsx +++ b/app/auth/signup/page.tsx @@ -1,32 +1,143 @@ 'use client'; import Link from 'next/link'; +import { Suspense, type FormEvent, useEffect, useMemo, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { AuthShell } from '@/components/auth/auth-shell'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { authClient } from '@/lib/auth-client'; + +function sanitizeNextPath(value: string | null) { + if (!value || !value.startsWith('/')) { + return '/'; + } + + return value; +} export default function SignUpPage() { + return ( + Loading sign up...}> + + + ); +} + +function SignUpPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const nextPath = useMemo(() => sanitizeNextPath(searchParams.get('next')), [searchParams]); + const { data: rawSession, isPending } = authClient.useSession(); + const session = (rawSession ?? null) as { user?: { id?: string } } | null; + + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!isPending && session?.user?.id) { + router.replace(nextPath); + } + }, [isPending, nextPath, router, session]); + + const signUp = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + if (password !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + + setBusy(true); + + const { error: signUpError } = await authClient.signUp.email({ + name: name.trim(), + email: email.trim(), + password, + callbackURL: nextPath + }); + + setBusy(false); + + if (signUpError) { + setError(signUpError.message || 'Unable to create account.'); + return; + } + + router.replace(nextPath); + }; + return ( - Already set?{' '} - - Launch dashboard + Already registered?{' '} + + Sign in )} > -

- For production deployment you can reintroduce full multi-user authentication, but this rebuild is intentionally self-contained for fast iteration. -

+
+
+ + setName(event.target.value)} + required + /> +
- - - +
); } diff --git a/components/shell/app-shell.tsx b/components/shell/app-shell.tsx index 57b7323..5a9404d 100644 --- a/components/shell/app-shell.tsx +++ b/components/shell/app-shell.tsx @@ -1,8 +1,11 @@ 'use client'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { Activity, BookOpenText, ChartCandlestick, Eye } from 'lucide-react'; +import { usePathname, useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { Activity, BookOpenText, ChartCandlestick, Eye, LogOut } from 'lucide-react'; +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; type AppShellProps = { @@ -21,6 +24,31 @@ const NAV_ITEMS = [ export function AppShell({ title, subtitle, actions, children }: AppShellProps) { const pathname = usePathname(); + const router = useRouter(); + const [isSigningOut, setIsSigningOut] = useState(false); + const { data: session } = authClient.useSession(); + const sessionUser = (session?.user ?? null) as { name?: string | null; email?: string | null; role?: unknown } | null; + + const role = typeof sessionUser?.role === 'string' + ? sessionUser.role + : Array.isArray(sessionUser?.role) + ? sessionUser.role.filter((entry): entry is string => typeof entry === 'string').join(', ') + : null; + const displayName = sessionUser?.name || sessionUser?.email || 'Authenticated user'; + + const signOut = async () => { + if (isSigningOut) { + return; + } + + setIsSigningOut(true); + try { + await authClient.signOut(); + router.replace('/auth/signin'); + } finally { + setIsSigningOut(false); + } + }; return (
@@ -62,10 +90,15 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)

Runtime

-

local operator mode

+

{displayName}

+ {role ?

Role: {role}

: null}

OpenClaw and market data are driven by environment configuration and live API tasks.

+
@@ -79,7 +112,13 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)

{subtitle}

) : null}
- {actions ?
{actions}
: null} +
+ {actions} + +
diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 0c5c640..ae026f7 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -2,3 +2,28 @@ services: app: ports: - '${APP_PORT:-3000}:3000' + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fiscal_clone} + BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3000} + BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:3000} + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: fiscal_clone + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d fiscal_clone'] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index 903fc6e..1128af7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,11 @@ services: PORT: 3000 HOSTNAME: 0.0.0.0 NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-} + DATABASE_URL: ${DATABASE_URL:-} + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-} + BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-} + BETTER_AUTH_ADMIN_USER_IDS: ${BETTER_AUTH_ADMIN_USER_IDS:-} + BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-} OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-} OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-} OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw} diff --git a/hooks/use-auth-guard.ts b/hooks/use-auth-guard.ts index 6afd307..f770ab8 100644 --- a/hooks/use-auth-guard.ts +++ b/hooks/use-auth-guard.ts @@ -1,16 +1,47 @@ 'use client'; -export function useAuthGuard() { +import { useEffect } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { authClient } from '@/lib/auth-client'; + +type UseAuthGuardOptions = { + required?: boolean; +}; + +export function useAuthGuard(options: UseAuthGuardOptions = {}) { + const { required = true } = options; + const pathname = usePathname(); + const router = useRouter(); + const { data: rawSession, isPending } = authClient.useSession(); + const session = (rawSession ?? null) as { + user?: { + id?: string; + name?: string | null; + email?: string | null; + image?: string | null; + }; + } | null; + + const isAuthenticated = Boolean(session?.user?.id); + + useEffect(() => { + if (!required || isPending || isAuthenticated || pathname.startsWith('/auth')) { + return; + } + + const currentPath = typeof window === 'undefined' + ? pathname + : `${window.location.pathname}${window.location.search}`; + const query = currentPath && currentPath !== '/' + ? `?next=${encodeURIComponent(currentPath)}` + : ''; + + router.replace(`/auth/signin${query}`); + }, [required, isPending, isAuthenticated, pathname, router]); + return { - session: { - user: { - id: 1, - name: 'Local Operator', - email: 'operator@local.fiscal', - image: null - } - }, - isPending: false, - isAuthenticated: true + session, + isPending, + isAuthenticated }; } diff --git a/lib/auth-client.ts b/lib/auth-client.ts new file mode 100644 index 0000000..cf9f4e5 --- /dev/null +++ b/lib/auth-client.ts @@ -0,0 +1,14 @@ +import { createAuthClient } from 'better-auth/react'; +import { adminClient, magicLinkClient, organizationClient } from 'better-auth/client/plugins'; +import { resolveApiBaseURL } from '@/lib/runtime-url'; + +const baseURL = resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL); + +export const authClient = createAuthClient({ + baseURL: baseURL || undefined, + plugins: [ + adminClient(), + magicLinkClient(), + organizationClient() + ] +}); diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..18af457 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,92 @@ +import { betterAuth } from 'better-auth'; +import { getMigrations } from 'better-auth/db'; +import { nextCookies } from 'better-auth/next-js'; +import { admin, magicLink, organization } from 'better-auth/plugins'; +import { Pool } from 'pg'; + +declare global { + // eslint-disable-next-line no-var + var __fiscalAuthPgPool: Pool | undefined; +} + +type BetterAuthInstance = ReturnType; + +let authInstance: BetterAuthInstance | null = null; +let migrationPromise: Promise | null = null; + +function parseCsvList(value: string | undefined) { + return (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function getPool() { + const connectionString = process.env.DATABASE_URL?.trim(); + if (!connectionString) { + throw new Error('DATABASE_URL is required for Better Auth PostgreSQL adapter.'); + } + + if (!globalThis.__fiscalAuthPgPool) { + globalThis.__fiscalAuthPgPool = new Pool({ connectionString }); + } + + return globalThis.__fiscalAuthPgPool; +} + +function buildAuth() { + const adminUserIds = parseCsvList(process.env.BETTER_AUTH_ADMIN_USER_IDS); + const trustedOrigins = parseCsvList(process.env.BETTER_AUTH_TRUSTED_ORIGINS); + const baseURL = process.env.BETTER_AUTH_BASE_URL?.trim() + || process.env.BETTER_AUTH_URL?.trim() + || undefined; + const secret = process.env.BETTER_AUTH_SECRET?.trim() || undefined; + + return betterAuth({ + database: getPool(), + baseURL, + secret, + emailAndPassword: { + enabled: true + }, + trustedOrigins: trustedOrigins.length > 0 ? trustedOrigins : undefined, + plugins: [ + admin(adminUserIds.length > 0 ? { adminUserIds } : undefined), + magicLink({ + sendMagicLink: async ({ email, url }) => { + console.info(`[better-auth] Magic link requested for ${email}: ${url}`); + } + }), + organization(), + nextCookies() + ] + }); +} + +export function getAuth() { + if (!authInstance) { + authInstance = buildAuth(); + } + + return authInstance; +} + +export async function ensureAuthSchema() { + const auth = getAuth(); + + if (!migrationPromise) { + migrationPromise = (async () => { + const { runMigrations } = await getMigrations(auth.options); + await runMigrations(); + })(); + } + + try { + await migrationPromise; + } catch (error) { + migrationPromise = null; + throw error; + } + + return auth; +} diff --git a/lib/server/auth-session.ts b/lib/server/auth-session.ts new file mode 100644 index 0000000..66fdf3f --- /dev/null +++ b/lib/server/auth-session.ts @@ -0,0 +1,121 @@ +import { headers } from 'next/headers'; +import { ensureAuthSchema } from '@/lib/auth'; +import { asErrorMessage, jsonError } from '@/lib/server/http'; + +type RecordValue = Record; + +export type AuthenticatedUser = { + id: string; + email: string; + name: string | null; + image: string | null; + role?: string | string[]; +}; + +export type AuthenticatedSession = { + user: AuthenticatedUser; + session: RecordValue | null; + raw: RecordValue; +}; + +const UNAUTHORIZED_SESSION: AuthenticatedSession = { + user: { + id: '', + email: '', + name: null, + image: null + }, + session: null, + raw: {} +}; + +function asRecord(value: unknown): RecordValue | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as RecordValue; +} + +function asString(value: unknown) { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function asNullableString(value: unknown) { + return typeof value === 'string' ? value : null; +} + +function normalizeRole(value: unknown) { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + const roles = value.filter((entry): entry is string => typeof entry === 'string'); + return roles.length > 0 ? roles : undefined; + } + + return undefined; +} + +function normalizeSession(rawSession: unknown): AuthenticatedSession | null { + const root = asRecord(rawSession); + if (!root) { + return null; + } + + const rootSession = asRecord(root.session); + const userRecord = asRecord(root.user) ?? asRecord(rootSession?.user); + if (!userRecord) { + return null; + } + + const id = asString(userRecord.id); + const email = asString(userRecord.email); + if (!id || !email) { + return null; + } + + return { + user: { + id, + email, + name: asNullableString(userRecord.name), + image: asNullableString(userRecord.image), + role: normalizeRole(userRecord.role) + }, + session: rootSession, + raw: root + }; +} + +export async function getAuthenticatedSession() { + const auth = await ensureAuthSchema(); + const session = await auth.api.getSession({ + headers: await headers() + }); + + return normalizeSession(session); +} + +export async function requireAuthenticatedSession() { + try { + const session = await getAuthenticatedSession(); + if (!session) { + return { + session: UNAUTHORIZED_SESSION, + response: jsonError('Unauthorized', 401) + }; + } + + return { + session, + response: null + }; + } catch (error) { + return { + session: UNAUTHORIZED_SESSION, + response: jsonError(asErrorMessage(error, 'Authentication subsystem is unavailable.'), 500) + }; + } +} diff --git a/lib/server/store.ts b/lib/server/store.ts index 2dc3fad..3744e75 100644 --- a/lib/server/store.ts +++ b/lib/server/store.ts @@ -21,13 +21,7 @@ const STORE_PATH = path.join(DATA_DIR, 'store.json'); let writeQueue = Promise.resolve(); -function nowIso() { - return new Date().toISOString(); -} - function createDefaultStore(): DataStore { - const now = nowIso(); - return { counters: { watchlist: 0, @@ -39,19 +33,7 @@ function createDefaultStore(): DataStore { holdings: [], filings: [], tasks: [], - insights: [ - { - id: 1, - user_id: 1, - provider: 'local-bootstrap', - model: 'zeroclaw', - content: [ - 'System initialized in local-first mode.', - 'Add holdings and sync filings to produce a live AI brief via OpenClaw.' - ].join('\n'), - created_at: now - } - ] + insights: [] }; } diff --git a/lib/server/tasks.ts b/lib/server/tasks.ts index 97d9ad0..000a995 100644 --- a/lib/server/tasks.ts +++ b/lib/server/tasks.ts @@ -7,6 +7,7 @@ import { fetchFilingMetrics, fetchRecentFilings } from '@/lib/server/sec'; import { getStoreSnapshot, withStore } from '@/lib/server/store'; type EnqueueTaskInput = { + userId: string; taskType: TaskType; payload?: Record; priority?: number; @@ -137,9 +138,15 @@ async function processSyncFilings(task: Task) { }; } -async function processRefreshPrices() { +async function processRefreshPrices(task: Task) { + const userId = task.user_id; + if (!userId) { + throw new Error('Task is missing user scope'); + } + const snapshot = await getStoreSnapshot(); - const tickers = [...new Set(snapshot.holdings.map((holding) => holding.ticker))]; + const userHoldings = snapshot.holdings.filter((holding) => holding.user_id === userId); + const tickers = [...new Set(userHoldings.map((holding) => holding.ticker))]; const quotes = new Map(); for (const ticker of tickers) { @@ -152,6 +159,10 @@ async function processRefreshPrices() { await withStore((store) => { store.holdings = store.holdings.map((holding) => { + if (holding.user_id !== userId) { + return holding; + } + const quote = quotes.get(holding.ticker); if (quote === undefined) { return holding; @@ -236,14 +247,20 @@ function holdingDigest(holdings: Holding[]) { })); } -async function processPortfolioInsights() { +async function processPortfolioInsights(task: Task) { + const userId = task.user_id; + if (!userId) { + throw new Error('Task is missing user scope'); + } + const snapshot = await getStoreSnapshot(); - const summary = buildPortfolioSummary(snapshot.holdings); + const userHoldings = snapshot.holdings.filter((holding) => holding.user_id === userId); + const summary = buildPortfolioSummary(userHoldings); const prompt = [ 'Generate portfolio intelligence with actionable recommendations.', `Portfolio summary: ${JSON.stringify(summary)}`, - `Holdings: ${JSON.stringify(holdingDigest(snapshot.holdings))}`, + `Holdings: ${JSON.stringify(holdingDigest(userHoldings))}`, 'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.' ].join('\n'); @@ -255,7 +272,7 @@ async function processPortfolioInsights() { const insight: PortfolioInsight = { id: store.counters.insights, - user_id: 1, + user_id: userId, provider: analysis.provider, model: analysis.model, content: analysis.text, @@ -277,11 +294,11 @@ async function runTaskProcessor(task: Task) { case 'sync_filings': return await processSyncFilings(task); case 'refresh_prices': - return await processRefreshPrices(); + return await processRefreshPrices(task); case 'analyze_filing': return await processAnalyzeFiling(task); case 'portfolio_insights': - return await processPortfolioInsights(); + return await processPortfolioInsights(task); default: throw new Error(`Unsupported task type: ${task.task_type}`); } @@ -356,6 +373,7 @@ export async function enqueueTask(input: EnqueueTaskInput) { const task: Task = { id: randomUUID(), + user_id: input.userId, task_type: input.taskType, status: 'queued', priority: input.priority ?? 50, @@ -384,18 +402,19 @@ export async function enqueueTask(input: EnqueueTaskInput) { return task; } -export async function getTaskById(taskId: string) { +export async function getTaskById(taskId: string, userId: string) { const snapshot = await getStoreSnapshot(); - return snapshot.tasks.find((task) => task.id === taskId) ?? null; + return snapshot.tasks.find((task) => task.id === taskId && task.user_id === userId) ?? null; } -export async function listRecentTasks(limit = 20, statuses?: TaskStatus[]) { +export async function listRecentTasks(userId: string, limit = 20, statuses?: TaskStatus[]) { const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200); const snapshot = await getStoreSnapshot(); + const scoped = snapshot.tasks.filter((task) => task.user_id === userId); const filtered = statuses && statuses.length > 0 - ? snapshot.tasks.filter((task) => statuses.includes(task.status)) - : snapshot.tasks; + ? scoped.filter((task) => statuses.includes(task.status)) + : scoped; return filtered .slice() diff --git a/lib/types.ts b/lib/types.ts index 9a74802..15d5312 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,5 +1,5 @@ export type User = { - id: number; + id: string; email: string; name: string | null; image: string | null; @@ -7,7 +7,7 @@ export type User = { export type WatchlistItem = { id: number; - user_id: number; + user_id: string; ticker: string; company_name: string; sector: string | null; @@ -16,7 +16,7 @@ export type WatchlistItem = { export type Holding = { id: number; - user_id: number; + user_id: string; ticker: string; shares: string; avg_cost: string; @@ -68,6 +68,7 @@ export type TaskType = 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'p export type Task = { id: string; + user_id: string; task_type: TaskType; status: TaskStatus; priority: number; @@ -83,7 +84,7 @@ export type Task = { export type PortfolioInsight = { id: number; - user_id: number; + user_id: string; provider: string; model: string; content: string; diff --git a/package-lock.json b/package-lock.json index 84479ab..80c27e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,19 @@ "version": "2.0.0", "dependencies": { "@tailwindcss/postcss": "^4.2.1", + "better-auth": "^1.4.19", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.575.0", "next": "^16.1.6", + "pg": "^8.18.0", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.7.0" }, "devDependencies": { "@types/node": "^25.3.0", + "@types/pg": "^8.16.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.24", @@ -39,6 +42,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@better-auth/core": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.19.tgz", + "integrity": "sha512-uADLHG1jc5BnEJi7f6ijUN5DmPPRSj++7m/G19z3UqA3MVCo4Y4t1MMa4IIxLCqGDFv22drdfxescgW+HnIowA==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "better-call": "1.1.8", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.19.tgz", + "integrity": "sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw==", + "dependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21" + }, + "peerDependencies": { + "@better-auth/core": "1.4.19" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", + "license": "MIT" + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -694,6 +737,30 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -1080,6 +1147,18 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1155,6 +1234,126 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.19.tgz", + "integrity": "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.4.19", + "@better-auth/telemetry": "1.4.19", + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.0.0", + "@noble/hashes": "^2.0.0", + "better-call": "1.1.8", + "defu": "^6.1.4", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": ">=0.41.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", + "integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.3.0", + "@better-fetch/fetch": "^1.1.4", + "rou3": "^0.7.10", + "set-cookie-parser": "^2.7.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1368,6 +1567,12 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1471,6 +1676,24 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/kysely": { + "version": "0.28.11", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", + "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -1756,6 +1979,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz", + "integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -1844,6 +2082,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1885,6 +2212,45 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -1987,6 +2353,12 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2006,6 +2378,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -2060,6 +2438,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2196,6 +2583,24 @@ "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index dfd633a..8846f33 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,19 @@ }, "dependencies": { "@tailwindcss/postcss": "^4.2.1", + "better-auth": "^1.4.19", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.575.0", "next": "^16.1.6", + "pg": "^8.18.0", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.7.0" }, "devDependencies": { "@types/node": "^25.3.0", + "@types/pg": "^8.16.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.24",