implement better-auth auth with postgres and route protection
This commit is contained in:
@@ -5,6 +5,13 @@ NPM_VERSION=latest
|
|||||||
# Local docker host port (used by docker-compose.override.yml)
|
# Local docker host port (used by docker-compose.override.yml)
|
||||||
APP_PORT=3000
|
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 / ZeroClaw (OpenAI-compatible)
|
||||||
OPENCLAW_BASE_URL=http://localhost:4000
|
OPENCLAW_BASE_URL=http://localhost:4000
|
||||||
OPENCLAW_API_KEY=replace-with-your-agent-key
|
OPENCLAW_API_KEY=replace-with-your-agent-key
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -6,6 +6,8 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration.
|
|||||||
|
|
||||||
- Next.js 16 App Router
|
- Next.js 16 App Router
|
||||||
- Turbopack for `dev` and `build`
|
- Turbopack for `dev` and `build`
|
||||||
|
- Better Auth (email/password, magic link, admin, organization plugins)
|
||||||
|
- PostgreSQL adapter for Better Auth
|
||||||
- Internal API routes (`app/api/*`)
|
- Internal API routes (`app/api/*`)
|
||||||
- Durable local task engine and JSON data store
|
- Durable local task engine and JSON data store
|
||||||
- OpenClaw/ZeroClaw analysis via OpenAI-compatible chat endpoint
|
- OpenClaw/ZeroClaw analysis via OpenAI-compatible chat endpoint
|
||||||
@@ -19,6 +21,9 @@ npm run dev
|
|||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000).
|
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
|
## Production build
|
||||||
|
|
||||||
```bash
|
```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`).
|
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.
|
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).
|
Runtime data persists in the `app_data` volume (`/app/data` in container).
|
||||||
Docker builds install the npm version from `NPM_VERSION` (default `latest`).
|
Docker builds install the npm version from `NPM_VERSION` (default `latest`).
|
||||||
@@ -45,6 +51,11 @@ Use root `.env` or root `.env.local`:
|
|||||||
```env
|
```env
|
||||||
# leave blank for same-origin API
|
# leave blank for same-origin API
|
||||||
NEXT_PUBLIC_API_URL=
|
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_BASE_URL=http://localhost:4000
|
||||||
OPENCLAW_API_KEY=your_key
|
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
|
## API surface
|
||||||
|
|
||||||
|
- `GET|POST|PATCH|PUT|DELETE /api/auth/*` (Better Auth handler)
|
||||||
- `GET /api/health`
|
- `GET /api/health`
|
||||||
- `GET /api/me`
|
- `GET /api/me`
|
||||||
- `GET|POST /api/watchlist`
|
- `GET|POST /api/watchlist`
|
||||||
|
|||||||
9
app/api/auth/[...all]/route.ts
Normal file
9
app/api/auth/[...all]/route.ts
Normal file
@@ -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;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { enqueueTask } from '@/lib/server/tasks';
|
import { enqueueTask } from '@/lib/server/tasks';
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
@@ -6,6 +7,11 @@ type Context = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(_request: Request, context: Context) {
|
export async function POST(_request: Request, context: Context) {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { accessionNumber } = await context.params;
|
const { accessionNumber } = await context.params;
|
||||||
|
|
||||||
@@ -14,6 +20,7 @@ export async function POST(_request: Request, context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const task = await enqueueTask({
|
const task = await enqueueTask({
|
||||||
|
userId: session.user.id,
|
||||||
taskType: 'analyze_filing',
|
taskType: 'analyze_filing',
|
||||||
payload: { accessionNumber: accessionNumber.trim() },
|
payload: { accessionNumber: accessionNumber.trim() },
|
||||||
priority: 65
|
priority: 65
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { getStoreSnapshot } from '@/lib/server/store';
|
import { getStoreSnapshot } from '@/lib/server/store';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
const { response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const tickerFilter = url.searchParams.get('ticker')?.trim().toUpperCase();
|
const tickerFilter = url.searchParams.get('ticker')?.trim().toUpperCase();
|
||||||
const limitValue = Number(url.searchParams.get('limit') ?? 50);
|
const limitValue = Number(url.searchParams.get('limit') ?? 50);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { enqueueTask } from '@/lib/server/tasks';
|
import { enqueueTask } from '@/lib/server/tasks';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await request.json() as {
|
const payload = await request.json() as {
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
@@ -13,6 +19,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const task = await enqueueTask({
|
const task = await enqueueTask({
|
||||||
|
userId: session.user.id,
|
||||||
taskType: 'sync_filings',
|
taskType: 'sync_filings',
|
||||||
payload: {
|
payload: {
|
||||||
ticker: payload.ticker.trim().toUpperCase(),
|
ticker: payload.ticker.trim().toUpperCase(),
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
user: {
|
user: {
|
||||||
id: 1,
|
id: session.user.id,
|
||||||
email: 'operator@local.fiscal',
|
email: session.user.email,
|
||||||
name: 'Local Operator',
|
name: session.user.name,
|
||||||
image: null
|
image: session.user.image
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { jsonError } from '@/lib/server/http';
|
import { jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { recalculateHolding } from '@/lib/server/portfolio';
|
import { recalculateHolding } from '@/lib/server/portfolio';
|
||||||
import { withStore } from '@/lib/server/store';
|
import { withStore } from '@/lib/server/store';
|
||||||
|
|
||||||
@@ -16,6 +17,12 @@ function asPositiveNumber(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: Request, context: Context) {
|
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 { id } = await context.params;
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
|
|
||||||
@@ -33,7 +40,7 @@ export async function PATCH(request: Request, context: Context) {
|
|||||||
let updated: unknown = null;
|
let updated: unknown = null;
|
||||||
|
|
||||||
await withStore((store) => {
|
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) {
|
if (index < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -66,6 +73,12 @@ export async function PATCH(request: Request, context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_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 { id } = await context.params;
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
|
|
||||||
@@ -76,7 +89,7 @@ export async function DELETE(_request: Request, context: Context) {
|
|||||||
let removed = false;
|
let removed = false;
|
||||||
|
|
||||||
await withStore((store) => {
|
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;
|
removed = next.length !== store.holdings.length;
|
||||||
store.holdings = next;
|
store.holdings = next;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Holding } from '@/lib/types';
|
import type { Holding } from '@/lib/types';
|
||||||
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { recalculateHolding } from '@/lib/server/portfolio';
|
import { recalculateHolding } from '@/lib/server/portfolio';
|
||||||
import { getStoreSnapshot, withStore } from '@/lib/server/store';
|
import { getStoreSnapshot, withStore } from '@/lib/server/store';
|
||||||
|
|
||||||
@@ -13,8 +14,15 @@ function asPositiveNumber(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
const snapshot = await getStoreSnapshot();
|
const snapshot = await getStoreSnapshot();
|
||||||
const holdings = snapshot.holdings
|
const holdings = snapshot.holdings
|
||||||
|
.filter((holding) => holding.user_id === userId)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => Number(b.market_value) - Number(a.market_value));
|
.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) {
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await request.json() as {
|
const payload = await request.json() as {
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
@@ -50,7 +65,7 @@ export async function POST(request: Request) {
|
|||||||
let holding: Holding | null = null;
|
let holding: Holding | null = null;
|
||||||
|
|
||||||
await withStore((store) => {
|
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;
|
const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost;
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
@@ -73,7 +88,7 @@ export async function POST(request: Request) {
|
|||||||
store.counters.holdings += 1;
|
store.counters.holdings += 1;
|
||||||
const created = recalculateHolding({
|
const created = recalculateHolding({
|
||||||
id: store.counters.holdings,
|
id: store.counters.holdings,
|
||||||
user_id: 1,
|
user_id: userId,
|
||||||
ticker,
|
ticker,
|
||||||
shares: shares.toFixed(6),
|
shares: shares.toFixed(6),
|
||||||
avg_cost: avgCost.toFixed(6),
|
avg_cost: avgCost.toFixed(6),
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { enqueueTask } from '@/lib/server/tasks';
|
import { enqueueTask } from '@/lib/server/tasks';
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const task = await enqueueTask({
|
const task = await enqueueTask({
|
||||||
|
userId: session.user.id,
|
||||||
taskType: 'portfolio_insights',
|
taskType: 'portfolio_insights',
|
||||||
payload: {},
|
payload: {},
|
||||||
priority: 70
|
priority: 70
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { getStoreSnapshot } from '@/lib/server/store';
|
import { getStoreSnapshot } from '@/lib/server/store';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
const snapshot = await getStoreSnapshot();
|
const snapshot = await getStoreSnapshot();
|
||||||
const insight = snapshot.insights
|
const insight = snapshot.insights
|
||||||
|
.filter((entry) => entry.user_id === userId)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0] ?? null;
|
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0] ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { enqueueTask } from '@/lib/server/tasks';
|
import { enqueueTask } from '@/lib/server/tasks';
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const task = await enqueueTask({
|
const task = await enqueueTask({
|
||||||
|
userId: session.user.id,
|
||||||
taskType: 'refresh_prices',
|
taskType: 'refresh_prices',
|
||||||
payload: {},
|
payload: {},
|
||||||
priority: 80
|
priority: 80
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { getStoreSnapshot } from '@/lib/server/store';
|
import { getStoreSnapshot } from '@/lib/server/store';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
const snapshot = await getStoreSnapshot();
|
const snapshot = await getStoreSnapshot();
|
||||||
const summary = buildPortfolioSummary(snapshot.holdings);
|
const summary = buildPortfolioSummary(snapshot.holdings.filter((holding) => holding.user_id === userId));
|
||||||
return Response.json({ summary });
|
return Response.json({ summary });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { jsonError } from '@/lib/server/http';
|
import { jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { getTaskById } from '@/lib/server/tasks';
|
import { getTaskById } from '@/lib/server/tasks';
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
@@ -6,8 +7,13 @@ type Context = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function GET(_request: Request, context: Context) {
|
export async function GET(_request: Request, context: Context) {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const { taskId } = await context.params;
|
const { taskId } = await context.params;
|
||||||
const task = await getTaskById(taskId);
|
const task = await getTaskById(taskId, session.user.id);
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return jsonError('Task not found', 404);
|
return jsonError('Task not found', 404);
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { TaskStatus } from '@/lib/types';
|
import type { TaskStatus } from '@/lib/types';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { listRecentTasks } from '@/lib/server/tasks';
|
import { listRecentTasks } from '@/lib/server/tasks';
|
||||||
|
|
||||||
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
|
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const limitValue = Number(url.searchParams.get('limit') ?? 20);
|
const limitValue = Number(url.searchParams.get('limit') ?? 20);
|
||||||
const limit = Number.isFinite(limitValue)
|
const limit = Number.isFinite(limitValue)
|
||||||
@@ -15,6 +21,6 @@ export async function GET(request: Request) {
|
|||||||
return ALLOWED_STATUSES.includes(status as TaskStatus);
|
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 });
|
return Response.json({ tasks });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { jsonError } from '@/lib/server/http';
|
import { jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { withStore } from '@/lib/server/store';
|
import { withStore } from '@/lib/server/store';
|
||||||
|
|
||||||
type Context = {
|
type Context = {
|
||||||
@@ -6,6 +7,12 @@ type Context = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function DELETE(_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 { id } = await context.params;
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
|
|
||||||
@@ -16,7 +23,7 @@ export async function DELETE(_request: Request, context: Context) {
|
|||||||
let removed = false;
|
let removed = false;
|
||||||
|
|
||||||
await withStore((store) => {
|
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;
|
removed = next.length !== store.watchlist.length;
|
||||||
store.watchlist = next;
|
store.watchlist = next;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { WatchlistItem } from '@/lib/types';
|
import type { WatchlistItem } from '@/lib/types';
|
||||||
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||||
|
import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||||
import { getStoreSnapshot, withStore } from '@/lib/server/store';
|
import { getStoreSnapshot, withStore } from '@/lib/server/store';
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
@@ -7,8 +8,15 @@ function nowIso() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
const snapshot = await getStoreSnapshot();
|
const snapshot = await getStoreSnapshot();
|
||||||
const items = snapshot.watchlist
|
const items = snapshot.watchlist
|
||||||
|
.filter((item) => item.user_id === userId)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
.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) {
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await request.json() as {
|
const payload = await request.json() as {
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
@@ -35,7 +50,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
await withStore((store) => {
|
await withStore((store) => {
|
||||||
const ticker = payload.ticker!.trim().toUpperCase();
|
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) {
|
if (existingIndex >= 0) {
|
||||||
const existing = store.watchlist[existingIndex];
|
const existing = store.watchlist[existingIndex];
|
||||||
@@ -53,7 +68,7 @@ export async function POST(request: Request) {
|
|||||||
store.counters.watchlist += 1;
|
store.counters.watchlist += 1;
|
||||||
const created: WatchlistItem = {
|
const created: WatchlistItem = {
|
||||||
id: store.counters.watchlist,
|
id: store.counters.watchlist,
|
||||||
user_id: 1,
|
user_id: userId,
|
||||||
ticker,
|
ticker,
|
||||||
company_name: payload.companyName!.trim(),
|
company_name: payload.companyName!.trim(),
|
||||||
sector: payload.sector?.trim() || null,
|
sector: payload.sector?.trim() || null,
|
||||||
|
|||||||
@@ -1,32 +1,151 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
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 { AuthShell } from '@/components/auth/auth-shell';
|
||||||
import { Button } from '@/components/ui/button';
|
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() {
|
export default function SignInPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading sign in...</div>}>
|
||||||
|
<SignInPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(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<HTMLFormElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
title="Local Runtime Mode"
|
title="Secure Sign In"
|
||||||
subtitle="Authentication is disabled in this rebuilt local-first environment."
|
subtitle="Use email/password or request a magic link."
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
Need multi-user auth later?{' '}
|
Need an account?{' '}
|
||||||
<Link href="/" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
<Link href={`/auth/signup${nextPath !== '/' ? `?next=${encodeURIComponent(nextPath)}` : ''}`} className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||||
Open command center
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">
|
<form className="space-y-4" onSubmit={signInWithPassword}>
|
||||||
Continue directly into the fiscal terminal. API routes are same-origin and task execution is fully local with OpenClaw support.
|
<div>
|
||||||
</p>
|
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Email</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link href="/" className="mt-6 block">
|
<div>
|
||||||
<Button type="button" className="w-full">
|
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Password</label>
|
||||||
Enter terminal
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
|
||||||
|
{message ? <p className="text-sm text-[#9fffcf]">{message}</p> : null}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={busyAction !== null}>
|
||||||
|
{busyAction === 'password' ? 'Signing in...' : 'Sign in with password'}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
onClick={() => void signInWithMagicLink()}
|
||||||
|
>
|
||||||
|
{busyAction === 'magic' ? 'Sending link...' : 'Send magic link'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,143 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
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 { AuthShell } from '@/components/auth/auth-shell';
|
||||||
import { Button } from '@/components/ui/button';
|
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() {
|
export default function SignUpPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading sign up...</div>}>
|
||||||
|
<SignUpPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && session?.user?.id) {
|
||||||
|
router.replace(nextPath);
|
||||||
|
}
|
||||||
|
}, [isPending, nextPath, router, session]);
|
||||||
|
|
||||||
|
const signUp = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
<AuthShell
|
<AuthShell
|
||||||
title="Workspace Provisioned"
|
title="Create Account"
|
||||||
subtitle="This clone now runs in local-operator mode and does not require account creation."
|
subtitle="Set up your operator profile to access portfolio and filings intelligence."
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
Already set?{' '}
|
Already registered?{' '}
|
||||||
<Link href="/" className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
<Link href={`/auth/signin${nextPath !== '/' ? `?next=${encodeURIComponent(nextPath)}` : ''}`} className="text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||||
Launch dashboard
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">
|
<form className="space-y-4" onSubmit={signUp}>
|
||||||
For production deployment you can reintroduce full multi-user authentication, but this rebuild is intentionally self-contained for fast iteration.
|
<div>
|
||||||
</p>
|
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Name</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link href="/" className="mt-6 block">
|
<div>
|
||||||
<Button type="button" className="w-full">
|
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Email</label>
|
||||||
Open fiscal desk
|
<Input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Password</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Confirm Password</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-[#ff9f9f]">{error}</p> : null}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={busy}>
|
||||||
|
{busy ? 'Creating account...' : 'Create account'}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { Activity, BookOpenText, ChartCandlestick, Eye } from 'lucide-react';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
@@ -21,6 +24,31 @@ const NAV_ITEMS = [
|
|||||||
|
|
||||||
export function AppShell({ title, subtitle, actions, children }: AppShellProps) {
|
export function AppShell({ title, subtitle, actions, children }: AppShellProps) {
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<div className="app-surface">
|
<div className="app-surface">
|
||||||
@@ -62,10 +90,15 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
|
|||||||
|
|
||||||
<div className="mt-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
<div className="mt-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Runtime</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Runtime</p>
|
||||||
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">local operator mode</p>
|
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p>
|
||||||
|
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null}
|
||||||
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
|
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
|
||||||
OpenClaw and market data are driven by environment configuration and live API tasks.
|
OpenClaw and market data are driven by environment configuration and live API tasks.
|
||||||
</p>
|
</p>
|
||||||
|
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -79,7 +112,13 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
|
|||||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
<Button variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,28 @@ services:
|
|||||||
app:
|
app:
|
||||||
ports:
|
ports:
|
||||||
- '${APP_PORT:-3000}:3000'
|
- '${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:
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ services:
|
|||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
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_BASE_URL: ${OPENCLAW_BASE_URL:-}
|
||||||
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
|
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
|
||||||
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
|
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
|
||||||
|
|||||||
@@ -1,16 +1,47 @@
|
|||||||
'use client';
|
'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 {
|
return {
|
||||||
session: {
|
session,
|
||||||
user: {
|
isPending,
|
||||||
id: 1,
|
isAuthenticated
|
||||||
name: 'Local Operator',
|
|
||||||
email: 'operator@local.fiscal',
|
|
||||||
image: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isPending: false,
|
|
||||||
isAuthenticated: true
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
14
lib/auth-client.ts
Normal file
14
lib/auth-client.ts
Normal file
@@ -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()
|
||||||
|
]
|
||||||
|
});
|
||||||
92
lib/auth.ts
Normal file
92
lib/auth.ts
Normal file
@@ -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<typeof betterAuth>;
|
||||||
|
|
||||||
|
let authInstance: BetterAuthInstance | null = null;
|
||||||
|
let migrationPromise: Promise<void> | 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;
|
||||||
|
}
|
||||||
121
lib/server/auth-session.ts
Normal file
121
lib/server/auth-session.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { ensureAuthSchema } from '@/lib/auth';
|
||||||
|
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||||
|
|
||||||
|
type RecordValue = Record<string, unknown>;
|
||||||
|
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,13 +21,7 @@ const STORE_PATH = path.join(DATA_DIR, 'store.json');
|
|||||||
|
|
||||||
let writeQueue = Promise.resolve();
|
let writeQueue = Promise.resolve();
|
||||||
|
|
||||||
function nowIso() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultStore(): DataStore {
|
function createDefaultStore(): DataStore {
|
||||||
const now = nowIso();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
counters: {
|
counters: {
|
||||||
watchlist: 0,
|
watchlist: 0,
|
||||||
@@ -39,19 +33,7 @@ function createDefaultStore(): DataStore {
|
|||||||
holdings: [],
|
holdings: [],
|
||||||
filings: [],
|
filings: [],
|
||||||
tasks: [],
|
tasks: [],
|
||||||
insights: [
|
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fetchFilingMetrics, fetchRecentFilings } from '@/lib/server/sec';
|
|||||||
import { getStoreSnapshot, withStore } from '@/lib/server/store';
|
import { getStoreSnapshot, withStore } from '@/lib/server/store';
|
||||||
|
|
||||||
type EnqueueTaskInput = {
|
type EnqueueTaskInput = {
|
||||||
|
userId: string;
|
||||||
taskType: TaskType;
|
taskType: TaskType;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
priority?: number;
|
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 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<string, number>();
|
const quotes = new Map<string, number>();
|
||||||
|
|
||||||
for (const ticker of tickers) {
|
for (const ticker of tickers) {
|
||||||
@@ -152,6 +159,10 @@ async function processRefreshPrices() {
|
|||||||
|
|
||||||
await withStore((store) => {
|
await withStore((store) => {
|
||||||
store.holdings = store.holdings.map((holding) => {
|
store.holdings = store.holdings.map((holding) => {
|
||||||
|
if (holding.user_id !== userId) {
|
||||||
|
return holding;
|
||||||
|
}
|
||||||
|
|
||||||
const quote = quotes.get(holding.ticker);
|
const quote = quotes.get(holding.ticker);
|
||||||
if (quote === undefined) {
|
if (quote === undefined) {
|
||||||
return holding;
|
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 snapshot = await getStoreSnapshot();
|
||||||
const summary = buildPortfolioSummary(snapshot.holdings);
|
const userHoldings = snapshot.holdings.filter((holding) => holding.user_id === userId);
|
||||||
|
const summary = buildPortfolioSummary(userHoldings);
|
||||||
|
|
||||||
const prompt = [
|
const prompt = [
|
||||||
'Generate portfolio intelligence with actionable recommendations.',
|
'Generate portfolio intelligence with actionable recommendations.',
|
||||||
`Portfolio summary: ${JSON.stringify(summary)}`,
|
`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.'
|
'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.'
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
@@ -255,7 +272,7 @@ async function processPortfolioInsights() {
|
|||||||
|
|
||||||
const insight: PortfolioInsight = {
|
const insight: PortfolioInsight = {
|
||||||
id: store.counters.insights,
|
id: store.counters.insights,
|
||||||
user_id: 1,
|
user_id: userId,
|
||||||
provider: analysis.provider,
|
provider: analysis.provider,
|
||||||
model: analysis.model,
|
model: analysis.model,
|
||||||
content: analysis.text,
|
content: analysis.text,
|
||||||
@@ -277,11 +294,11 @@ async function runTaskProcessor(task: Task) {
|
|||||||
case 'sync_filings':
|
case 'sync_filings':
|
||||||
return await processSyncFilings(task);
|
return await processSyncFilings(task);
|
||||||
case 'refresh_prices':
|
case 'refresh_prices':
|
||||||
return await processRefreshPrices();
|
return await processRefreshPrices(task);
|
||||||
case 'analyze_filing':
|
case 'analyze_filing':
|
||||||
return await processAnalyzeFiling(task);
|
return await processAnalyzeFiling(task);
|
||||||
case 'portfolio_insights':
|
case 'portfolio_insights':
|
||||||
return await processPortfolioInsights();
|
return await processPortfolioInsights(task);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported task type: ${task.task_type}`);
|
throw new Error(`Unsupported task type: ${task.task_type}`);
|
||||||
}
|
}
|
||||||
@@ -356,6 +373,7 @@ export async function enqueueTask(input: EnqueueTaskInput) {
|
|||||||
|
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
user_id: input.userId,
|
||||||
task_type: input.taskType,
|
task_type: input.taskType,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
priority: input.priority ?? 50,
|
priority: input.priority ?? 50,
|
||||||
@@ -384,18 +402,19 @@ export async function enqueueTask(input: EnqueueTaskInput) {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTaskById(taskId: string) {
|
export async function getTaskById(taskId: string, userId: string) {
|
||||||
const snapshot = await getStoreSnapshot();
|
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 safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 200);
|
||||||
const snapshot = await getStoreSnapshot();
|
const snapshot = await getStoreSnapshot();
|
||||||
|
const scoped = snapshot.tasks.filter((task) => task.user_id === userId);
|
||||||
|
|
||||||
const filtered = statuses && statuses.length > 0
|
const filtered = statuses && statuses.length > 0
|
||||||
? snapshot.tasks.filter((task) => statuses.includes(task.status))
|
? scoped.filter((task) => statuses.includes(task.status))
|
||||||
: snapshot.tasks;
|
: scoped;
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
.slice()
|
.slice()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type User = {
|
export type User = {
|
||||||
id: number;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
@@ -7,7 +7,7 @@ export type User = {
|
|||||||
|
|
||||||
export type WatchlistItem = {
|
export type WatchlistItem = {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: string;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
company_name: string;
|
company_name: string;
|
||||||
sector: string | null;
|
sector: string | null;
|
||||||
@@ -16,7 +16,7 @@ export type WatchlistItem = {
|
|||||||
|
|
||||||
export type Holding = {
|
export type Holding = {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: string;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
shares: string;
|
shares: string;
|
||||||
avg_cost: string;
|
avg_cost: string;
|
||||||
@@ -68,6 +68,7 @@ export type TaskType = 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'p
|
|||||||
|
|
||||||
export type Task = {
|
export type Task = {
|
||||||
id: string;
|
id: string;
|
||||||
|
user_id: string;
|
||||||
task_type: TaskType;
|
task_type: TaskType;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -83,7 +84,7 @@ export type Task = {
|
|||||||
|
|
||||||
export type PortfolioInsight = {
|
export type PortfolioInsight = {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
405
package-lock.json
generated
405
package-lock.json
generated
@@ -9,16 +9,19 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"better-auth": "^1.4.19",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
@@ -39,6 +42,46 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
@@ -694,6 +737,30 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@reduxjs/toolkit": {
|
||||||
"version": "2.11.2",
|
"version": "2.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
@@ -1080,6 +1147,18 @@
|
|||||||
"undici-types": "~7.18.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -1155,6 +1234,126 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -1368,6 +1567,12 @@
|
|||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1471,6 +1676,24 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.31.1",
|
"version": "1.31.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
"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": "^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": {
|
"node_modules/next": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
||||||
@@ -1844,6 +2082,95 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1885,6 +2212,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
@@ -1987,6 +2353,12 @@
|
|||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2006,6 +2378,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
@@ -2060,6 +2438,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
@@ -2196,6 +2583,24 @@
|
|||||||
"d3-time": "^3.0.0",
|
"d3-time": "^3.0.0",
|
||||||
"d3-timer": "^3.0.1"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"better-auth": "^1.4.19",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
|
|||||||
Reference in New Issue
Block a user