implemented the udpated changes

This commit is contained in:
metatroncubeswdev 2026-03-14 08:51:48 -04:00
parent fe6dcfd4f6
commit cf6c4005dd
86 changed files with 7616 additions and 3692 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
.next
.env
.env.local
*.log
.git

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm run build
# ─── Stage 2: Production ─────────────────────────────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3052
CMD ["node", "server.js"]

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function DELETE(req: NextRequest) {
return proxyRequest(req, "2fa/disable");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/enable");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/generate");
}

View File

@ -1,12 +1,6 @@
export async function POST() {
const res = await fetch("http://localhost:3051/api/accounts/link", {
method: "POST"
});
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts/link-token");
}

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/accounts/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "accounts/manual");
}

View File

@ -1,13 +1,6 @@
export async function GET(req: Request) {
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051"
const url = new URL(req.url);
const query = url.searchParams.toString();
const res = await fetch(`${baseUrl}/api/accounts${query ? `?${query}` : ""}`);
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/forgot-password");
}

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/login");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/logout");
}

6
app/api/auth/me/route.ts Normal file
View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me");
}

View File

@ -1,17 +1,10 @@
export async function PATCH(req: Request) {
const body = await req.text();
const auth = req.headers.get("authorization") ?? "";
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/auth/profile`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: auth },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me");
}
export async function PATCH(req: NextRequest) {
return proxyRequest(req, "auth/profile");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/refresh");
}

View File

@ -1,26 +1,6 @@
export async function POST(req: Request) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET() {
return new Response(
JSON.stringify({
ok: true,
message: "POST JSON { email, password } to /api/auth/register."
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/register");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/reset-password");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/verify-email");
}

View File

@ -1,11 +1,6 @@
export async function GET(req: Request) {
const url = new URL(req.url);
const res = await fetch(`http://localhost:3051/api/exports/csv${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "exports/csv");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "exports/sheets");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "google/connect");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function DELETE(req: NextRequest) {
return proxyRequest(req, "google/disconnect");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "google/exchange");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "google/status");
}

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/plaid/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/exchange");
}

View File

@ -1,14 +1,6 @@
export async function POST() {
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/plaid/link-token`, {
method: "POST",
headers: { "Content-Type": "application/json" }
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/link-token");
}

View File

@ -1,27 +1,10 @@
export async function GET(req: Request) {
const url = new URL(req.url);
const res = await fetch(`http://localhost:3051/api/rules${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "rules");
}
export async function POST(req: Request) {
const body = await req.text();
const res = await fetch("http://localhost:3051/api/rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
export async function POST(req: NextRequest) {
return proxyRequest(req, "rules");
}

View File

@ -1,11 +1,6 @@
export async function GET(req: Request) {
const url = new URL(req.url);
const res = await fetch(`http://localhost:3051/api/rules/suggestions${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "rules/suggestions");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/checkout");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/portal");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "stripe/subscription");
}

View File

@ -1,15 +1,9 @@
export async function POST(
_req: Request,
context: { params: { id: string } }
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const res = await fetch(`http://localhost:3051/api/tax/returns/${context.params.id}/export`, {
method: "POST"
});
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
return proxyRequest(req, `tax/returns/${params.id}/export`);
}

View File

@ -1,27 +1,10 @@
export async function GET(req: Request) {
const url = new URL(req.url);
const res = await fetch(`http://localhost:3051/api/tax/returns${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "tax/returns");
}
export async function POST(req: Request) {
const body = await req.text();
const res = await fetch("http://localhost:3051/api/tax/returns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
export async function POST(req: NextRequest) {
return proxyRequest(req, "tax/returns");
}

View File

@ -1,27 +1,9 @@
export async function PATCH(req: Request, context: { params: { id: string } }) {
try {
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const payload = await req.text();
const res = await fetch(`${baseUrl}/api/transactions/${context.params.id}/derived`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: payload
});
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, `transactions/${params.id}/derived`);
}

View File

@ -1,23 +1,6 @@
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions/cashflow${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: [],
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/cashflow");
}

View File

@ -0,0 +1,6 @@
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/import");
}

View File

@ -1,27 +1,6 @@
export async function POST(req: Request) {
try {
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const payload = await req.text();
const res = await fetch(`${baseUrl}/api/transactions/manual`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload
});
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/manual");
}

View File

@ -1,23 +1,6 @@
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions/merchants${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: [],
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/merchants");
}

View File

@ -1,23 +1,10 @@
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions${url.search}`);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
} catch {
return new Response(
JSON.stringify({
data: [],
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." }
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions");
}

View File

@ -1,13 +1,6 @@
export async function GET(req: Request) {
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const url = new URL(req.url);
const query = url.searchParams.toString();
const res = await fetch(`${baseUrl}/api/transactions/summary${query ? `?${query}` : ""}`);
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/summary");
}

View File

@ -1,16 +1,6 @@
export async function POST(req: Request) {
const body = await req.text();
const baseUrl = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
const res = await fetch(`${baseUrl}/api/transactions/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body
});
const payload = await res.text();
return new Response(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json"
}
});
import { NextRequest } from "next/server";
import { proxyRequest } from "@/lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/sync");
}

View File

@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AppShell } from "../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
@ -31,36 +32,14 @@ type MerchantInsight = {
count: number;
};
const recentTransactions = [
{
date: "Oct 24, 2023",
description: "Whole Foods Market",
category: "Groceries",
account: "Chase Sapphire",
amount: "-$142.30"
},
{
date: "Oct 23, 2023",
description: "Apple Subscription",
category: "Services",
account: "Apple Card",
amount: "-$14.99"
},
{
date: "Oct 22, 2023",
description: "Stripe Payout",
category: "Income",
account: "Mercury Business",
amount: "+$4,200.00"
},
{
date: "Oct 22, 2023",
description: "Shell Gasoline",
category: "Transport",
account: "Chase Sapphire",
amount: "-$52.12"
}
];
type TxRow = {
id: string;
date: string;
description: string;
category?: string | null;
accountId?: string | null;
amount: string;
};
// ---------- helpers ----------
function formatMonthLabel(yyyyMm: string) {
@ -520,27 +499,23 @@ export default function AppHomePage() {
const [cashflow, setCashflow] = useState<CashflowPoint[]>([]);
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
const [accountCount, setAccountCount] = useState<number | null>(null);
const [recentTxs, setRecentTxs] = useState<TxRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setLoading(false);
return;
}
const query = `?user_id=${encodeURIComponent(userId)}`;
Promise.all([
fetch(`/api/transactions/summary${query}`).then((res) => res.json() as Promise<ApiResponse<Summary>>),
fetch(`/api/transactions/cashflow${query}&months=6`).then((res) => res.json() as Promise<ApiResponse<CashflowPoint[]>>),
fetch(`/api/transactions/merchants${query}&limit=5`).then((res) => res.json() as Promise<ApiResponse<MerchantInsight[]>>),
fetch(`/api/accounts${query}`).then((res) => res.json() as Promise<ApiResponse<{ id: string }[]>>)
apiFetch<Summary>("/api/transactions/summary"),
apiFetch<CashflowPoint[]>("/api/transactions/cashflow?months=6"),
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=5"),
apiFetch<{ accounts: { id: string }[]; total: number }>("/api/accounts"),
apiFetch<{ transactions: TxRow[]; total: number }>("/api/transactions?limit=5"),
])
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes]) => {
.then(([summaryRes, cashflowRes, merchantsRes, accountsRes, txRes]) => {
if (!summaryRes.error) setSummary(summaryRes.data);
if (!cashflowRes.error) setCashflow(cashflowRes.data);
if (!merchantsRes.error) setMerchants(merchantsRes.data);
if (!accountsRes.error) setAccountCount(accountsRes.data.length);
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
if (!accountsRes.error) setAccountCount(accountsRes.data?.accounts?.length ?? 0);
if (!txRes.error) setRecentTxs(txRes.data?.transactions ?? []);
})
.catch(() => undefined)
.finally(() => setLoading(false));
@ -743,26 +718,48 @@ export default function AppHomePage() {
<th className="pb-3 pl-2">Date</th>
<th className="pb-3">Description</th>
<th className="pb-3">Category</th>
<th className="pb-3">Account</th>
<th className="pb-3 pr-2 text-right">Amount</th>
</tr>
</thead>
<tbody>
{recentTransactions.map((tx) => (
<tr key={`${tx.date}-${tx.description}`} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{tx.date}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
{tx.category}
</span>
</td>
<td className="py-3">{tx.account}</td>
<td className={`py-3 pr-2 text-right font-bold ${tx.amount.startsWith("+") ? "text-primary" : "text-foreground"}`}>
{tx.amount}
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<tr key={i} className="border-b border-border">
<td className="py-3 pl-2"><div className="h-3 w-20 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3"><div className="h-3 w-32 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse" /></td>
<td className="py-3 pr-2"><div className="h-3 w-16 bg-secondary/60 rounded animate-pulse ml-auto" /></td>
</tr>
))
) : recentTxs.length ? (
recentTxs.map((tx) => {
const amt = Number.parseFloat(tx.amount ?? "0");
const fmtAmt = formatCurrency(amt);
const isIncome = amt >= 0;
return (
<tr key={tx.id} className="border-b border-border hover:bg-secondary/30 transition-colors">
<td className="py-3 pl-2 font-medium">{new Date(tx.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}</td>
<td className="py-3 text-foreground font-medium">{tx.description}</td>
<td className="py-3">
{tx.category ? (
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">
{tx.category}
</span>
) : <span className="text-muted-foreground"></span>}
</td>
<td className={`py-3 pr-2 text-right font-bold ${isIncome ? "text-primary" : "text-foreground"}`}>
{fmtAmt}
</td>
</tr>
);
})
) : (
<tr>
<td colSpan={4} className="py-8 text-center text-muted-foreground">
No transactions yet. Connect a bank account to get started.
</td>
</tr>
))}
)}
</tbody>
</table>
</div>

View File

@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
export default function GoogleCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
const code = searchParams.get("code");
const error = searchParams.get("error");
if (error) {
setStatus("error");
setMessage(error === "access_denied" ? "You declined Google access." : `Google returned an error: ${error}`);
return;
}
if (!code) {
setStatus("error");
setMessage("No authorization code received from Google.");
return;
}
apiFetch<{ connected: boolean; googleEmail: string }>("/api/google/exchange", {
method: "POST",
body: JSON.stringify({ code }),
}).then((res) => {
if (res.error) {
setStatus("error");
setMessage(res.error.message ?? "Failed to connect Google account.");
} else {
setStatus("success");
setMessage(`Connected as ${res.data?.googleEmail ?? "your Google account"}.`);
setTimeout(() => router.replace("/exports"), 2000);
}
});
}, [searchParams, router]);
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="glass-panel rounded-2xl p-10 text-center max-w-sm w-full shadow-lg">
{status === "loading" && (
<>
<div className="h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Connecting your Google account...</p>
</>
)}
{status === "success" && (
<>
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<svg className="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-semibold text-foreground">Google Connected!</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
<p className="text-xs text-muted-foreground mt-2">Redirecting to Exports...</p>
</>
)}
{status === "error" && (
<>
<div className="h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center mx-auto mb-4">
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm font-semibold text-foreground">Connection Failed</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
<button
onClick={() => router.replace("/exports")}
className="mt-4 text-xs text-primary hover:underline"
>
Back to Exports
</button>
</>
)}
</div>
</div>
);
}

View File

@ -1,19 +1,21 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
import { apiFetch } from "@/lib/api";
type ExportData = { status: string; csv?: string; rowCount?: number };
type SheetsData = { spreadsheetUrl?: string; url?: string; spreadsheetId?: string; rowCount?: number };
type GoogleStatus = { connected: boolean; googleEmail?: string; connectedAt?: string };
export default function ExportsPage() {
const [status, setStatus] = useState("");
const [csvStatus, setCsvStatus] = useState("");
const [sheetsStatus, setSheetsStatus] = useState("");
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
const [sheetsLoading, setSheetsLoading] = useState(false);
const [datePreset, setDatePreset] = useState("custom");
const [googleStatus, setGoogleStatus] = useState<GoogleStatus | null>(null);
const [disconnecting, setDisconnecting] = useState(false);
const [filters, setFilters] = useState({
startDate: "",
endDate: "",
@ -21,14 +23,18 @@ export default function ExportsPage() {
maxAmount: "",
category: "",
source: "",
includeHidden: false
includeHidden: false,
});
useEffect(() => {
apiFetch<GoogleStatus>("/api/google/status").then((res) => {
if (!res.error) setGoogleStatus(res.data ?? { connected: false });
});
}, []);
const applyPreset = (preset: string) => {
setDatePreset(preset);
if (preset === "custom") {
return;
}
if (preset === "custom") return;
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let start = new Date(end);
@ -43,101 +49,104 @@ export default function ExportsPage() {
start = new Date(end.getFullYear() - 1, 0, 1);
end.setMonth(11, 31);
}
const format = (value: Date) => value.toISOString().slice(0, 10);
setFilters((prev) => ({
...prev,
startDate: format(start),
endDate: format(end)
}));
const fmt = (d: Date) => d.toISOString().slice(0, 10);
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
};
const onExport = async () => {
setStatus("Generating export...");
const userId = localStorage.getItem("ledgerone_user_id");
const buildParams = () => {
const params = new URLSearchParams();
if (userId) {
params.set("user_id", userId);
}
if (filters.startDate) {
params.set("start_date", filters.startDate);
}
if (filters.endDate) {
params.set("end_date", filters.endDate);
}
if (filters.minAmount) {
params.set("min_amount", filters.minAmount);
}
if (filters.maxAmount) {
params.set("max_amount", filters.maxAmount);
}
if (filters.category) {
params.set("category", filters.category);
}
if (filters.source) {
params.set("source", filters.source);
}
if (filters.includeHidden) {
params.set("include_hidden", "true");
}
if (filters.startDate) params.set("start_date", filters.startDate);
if (filters.endDate) params.set("end_date", filters.endDate);
if (filters.minAmount) params.set("min_amount", filters.minAmount);
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
if (filters.category) params.set("category", filters.category);
if (filters.source) params.set("source", filters.source);
if (filters.includeHidden) params.set("include_hidden", "true");
return params;
};
const onExportCsv = async () => {
setCsvStatus("Generating export...");
const params = buildParams();
const query = params.toString() ? `?${params.toString()}` : "";
try {
const res = await fetch(`/api/exports/csv${query}`);
const payload = (await res.json()) as ApiResponse<ExportData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Export failed.");
return;
}
if (payload.data.csv) {
const blob = new Blob([payload.data.csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
setStatus(`Export ready (${payload.data.rowCount ?? 0} rows).`);
} else {
setStatus("Export ready.");
}
} catch {
setStatus("Export failed.");
const res = await apiFetch<ExportData>(`/api/exports/csv${query}`);
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
if (res.data?.csv) {
const blob = new Blob([res.data.csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
setCsvStatus(`Export ready (${res.data.rowCount ?? 0} rows) — file downloaded.`);
} else {
setCsvStatus("Export ready.");
}
};
const onConnectGoogle = async () => {
const res = await apiFetch<{ authUrl: string }>("/api/google/connect");
if (res.error) {
setSheetsStatus(res.error.message ?? "Failed to get Google auth URL.");
return;
}
if (res.data?.authUrl) {
window.location.href = res.data.authUrl;
}
};
const onDisconnectGoogle = async () => {
setDisconnecting(true);
const res = await apiFetch("/api/google/disconnect", { method: "DELETE" });
setDisconnecting(false);
if (!res.error) {
setGoogleStatus({ connected: false });
setSheetsStatus("Google account disconnected.");
setSheetsUrl(null);
}
};
const onExportSheets = async () => {
setSheetsLoading(true);
setSheetsStatus("Creating Google Sheet...");
setSheetsUrl(null);
const body: Record<string, unknown> = {};
if (filters.startDate) body.startDate = filters.startDate;
if (filters.endDate) body.endDate = filters.endDate;
if (filters.minAmount) body.minAmount = filters.minAmount;
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
if (filters.category) body.category = filters.category;
if (filters.includeHidden) body.includeHidden = true;
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
method: "POST",
body: JSON.stringify(body),
});
setSheetsLoading(false);
if (res.error) {
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
return;
}
const url = res.data?.url ?? res.data?.spreadsheetUrl ?? null;
if (url) {
setSheetsUrl(url);
setSheetsStatus(`Sheet created with ${res.data?.rowCount ?? 0} rows.`);
} else {
setSheetsStatus("Sheet created.");
}
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return (
<AppShell
title="Exports"
subtitle="Generate CSV datasets with raw and derived fields."
>
<div className="glass-panel p-8 rounded-2xl shadow-sm">
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
{/* Filters */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Start date</label>
<input
type="date"
value={filters.startDate}
onChange={(event) =>
setFilters((prev) => ({ ...prev, startDate: event.target.value }))
}
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
disabled={datePreset !== "custom"}
/>
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">End date</label>
<input
type="date"
value={filters.endDate}
onChange={(event) =>
setFilters((prev) => ({ ...prev, endDate: event.target.value }))
}
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
disabled={datePreset !== "custom"}
/>
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Date range</label>
<select
value={datePreset}
onChange={(event) => applyPreset(event.target.value)}
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
>
<label className={labelCls}>Date range</label>
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
<option value="custom">Custom</option>
<option value="this_month">This month</option>
<option value="last_month">Last month</option>
@ -146,96 +155,114 @@ export default function ExportsPage() {
</select>
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Category contains</label>
<input
type="text"
value={filters.category}
onChange={(event) =>
setFilters((prev) => ({ ...prev, category: event.target.value }))
}
placeholder="Dining, Payroll, Utilities"
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
/>
<label className={labelCls}>Start date</label>
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Min amount</label>
<input
type="number"
value={filters.minAmount}
onChange={(event) =>
setFilters((prev) => ({ ...prev, minAmount: event.target.value }))
}
placeholder="0.00"
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
/>
<label className={labelCls}>End date</label>
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Max amount</label>
<input
type="number"
value={filters.maxAmount}
onChange={(event) =>
setFilters((prev) => ({ ...prev, maxAmount: event.target.value }))
}
placeholder="10000.00"
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
/>
<label className={labelCls}>Category contains</label>
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div>
<div>
<label className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">Source contains</label>
<input
type="text"
value={filters.source}
onChange={(event) =>
setFilters((prev) => ({ ...prev, source: event.target.value }))
}
placeholder="plaid"
className="mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary"
/>
<label className={labelCls}>Min amount ($)</label>
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Max amount ($)</label>
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Include hidden transactions
</label>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-xs text-muted-foreground font-medium">
<input
type="checkbox"
checked={filters.includeHidden}
onChange={(event) =>
setFilters((prev) => ({ ...prev, includeHidden: event.target.checked }))
}
className="rounded border-border text-primary focus:ring-primary"
/>
Include hidden transactions
</label>
</div>
<div className="border-t border-border" />
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onExport}
className="rounded-full bg-primary px-6 py-3 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
Generate CSV export
</button>
<button
type="button"
onClick={() =>
setFilters({
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
category: "",
source: "",
includeHidden: false
})
}
className="rounded-full border border-border bg-background px-6 py-3 text-sm font-semibold text-foreground hover:bg-secondary transition-colors"
>
Reset filters
</button>
{/* Export cards */}
<div className="grid gap-4 md:grid-cols-2">
{/* CSV */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download CSV</p>
<p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
</div>
</div>
<button
onClick={onExportCsv}
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Export CSV
</button>
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
</div>
{/* Google Sheets */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
{googleStatus?.connected ? (
<div className="flex items-center gap-1 mt-1">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
<p className="text-xs text-green-600 dark:text-green-400 truncate">{googleStatus.googleEmail}</p>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">Connect your Google account to export directly to Sheets.</p>
)}
</div>
</div>
{googleStatus?.connected ? (
<>
<button
onClick={onExportSheets}
disabled={sheetsLoading}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
>
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
</button>
<button
onClick={onDisconnectGoogle}
disabled={disconnecting}
className="mt-2 w-full rounded-lg py-1.5 px-4 text-xs text-muted-foreground hover:text-foreground transition-all"
>
{disconnecting ? "Disconnecting..." : "Disconnect Google account"}
</button>
</>
) : (
<button
onClick={onConnectGoogle}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all"
>
Connect Google Account
</button>
)}
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
{sheetsUrl && (
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
Open Sheet
</a>
)}
</div>
</div>
{status ? <p className="mt-4 text-xs font-medium text-primary">{status}</p> : null}
</div>
</AppShell>
);

View File

@ -0,0 +1,115 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("");
const [sent, setSent] = useState(false);
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Sending reset link...");
setIsError(false);
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const payload = (await res.json()) as ApiResponse<{ message: string }>;
if (!res.ok && payload.error) {
setStatus(payload.error.message ?? "Something went wrong.");
setIsError(true);
return;
}
setSent(true);
setStatus(payload.data?.message ?? "If that email exists, a reset link has been sent.");
} catch {
setStatus("Something went wrong. Please try again.");
setIsError(true);
}
};
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Back to sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
{sent ? (
<div className="text-center space-y-4">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<p className="text-sm text-foreground font-medium">{status}</p>
<p className="text-xs text-muted-foreground">Check your email inbox and spam folder.</p>
<Link href="/login" className="inline-flex justify-center rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all">
Back to sign in
</Link>
</div>
) : (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<p className="mt-1 text-xs text-muted-foreground">
We&#39;ll send a reset link to this address if it has an account.
</p>
<div className="mt-2">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Send reset link
</button>
{isError && status && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-4">
<p className="text-sm text-center">{status}</p>
</div>
)}
</form>
)}
</div>
</div>
</div>
<SiteFooter />
</div>
);
}

View File

@ -1,16 +1,14 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ContactSection } from "../../components/contact-section";
import { DemoCta } from "../../components/demo-cta";
import { FaqSection } from "../../components/faq-section";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
import { storeAuthTokens } from "@/lib/api";
type ApiResponse<T> = {
data: T;
@ -18,20 +16,137 @@ type ApiResponse<T> = {
error: null | { message: string; code?: string };
};
type AuthData = { user: { id: string; email: string }; token: string };
type AuthData = {
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
accessToken: string;
refreshToken: string;
requiresTwoFactor?: boolean;
};
export default function LoginPage() {
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") ?? "/app";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpToken, setTotpToken] = useState("");
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
const [status, setStatus] = useState<string>("");
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Signing in...");
setIsError(false);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, ...(requiresTwoFactor ? { totpToken } : {}) }),
});
const payload = (await res.json()) as ApiResponse<AuthData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Login failed.");
setIsError(true);
return;
}
if (payload.data.requiresTwoFactor) {
setRequiresTwoFactor(true);
setStatus("Enter the code from your authenticator app.");
return;
}
storeAuthTokens({
accessToken: payload.data.accessToken,
refreshToken: payload.data.refreshToken,
user: payload.data.user,
});
setStatus(`Welcome back, ${payload.data.user.email}`);
router.push(nextPath);
} catch {
setStatus("Login failed. Please try again.");
setIsError(true);
}
};
return (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<div className="mt-1">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="current-password" required
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
{requiresTwoFactor && (
<div>
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
Authenticator Code
</label>
<div className="mt-1">
<input
id="totp" name="totp" type="text" inputMode="numeric" maxLength={6}
placeholder="6-digit code" autoComplete="one-time-code" required
value={totpToken} onChange={(e) => setTotpToken(e.target.value.replace(/\D/g, ""))}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all tracking-widest text-center"
/>
</div>
</div>
)}
<div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
{requiresTwoFactor ? "Verify" : "Sign in"}
</button>
</div>
<div className="text-center">
<Link href="/forgot-password" className="text-sm text-muted-foreground hover:text-primary transition-colors">
Forgot your password?
</Link>
</div>
{status && (
<div className={`mt-4 rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
<p className="text-sm font-medium text-center">{status}</p>
</div>
)}
</form>
);
}
export default function LoginPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Login",
description: "Sign in to LedgerOne to access your audit-ready ledger.",
url: `${siteInfo.url}/login`
url: `${siteInfo.url}/login`,
},
{
"@context": "https://schema.org",
@ -39,41 +154,16 @@ export default function LoginPage() {
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer }
}))
}
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
},
];
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Signing in...");
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const payload = (await res.json()) as ApiResponse<AuthData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Login failed.");
return;
}
setStatus(`Welcome back, ${payload.data.user.email}`);
localStorage.setItem("ledgerone_token", payload.data.token);
localStorage.setItem("ledgerone_user_id", payload.data.user.id);
router.push("/app");
} catch {
setStatus("Login failed.");
}
};
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
@ -90,69 +180,14 @@ export default function LoginPage() {
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Sign in
</button>
</div>
</form>
{status && (
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3>
</div>
</div>
</div>
)}
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>

View File

@ -3,14 +3,12 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, FormEvent } from "react";
import { ContactSection } from "../../components/contact-section";
import { DemoCta } from "../../components/demo-cta";
import { FaqSection } from "../../components/faq-section";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
import { storeAuthTokens } from "@/lib/api";
type ApiResponse<T> = {
data: T;
@ -18,20 +16,27 @@ type ApiResponse<T> = {
error: null | { message: string; code?: string };
};
type AuthData = { user: { id: string; email: string }; token: string };
type AuthData = {
user: { id: string; email: string; fullName?: string };
accessToken: string;
refreshToken: string;
message?: string;
};
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState<string>("");
const [isError, setIsError] = useState(false);
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Create Account",
description: "Create a LedgerOne account and start with two free accounts.",
url: `${siteInfo.url}/register`
url: `${siteInfo.url}/register`,
},
{
"@context": "https://schema.org",
@ -39,41 +44,45 @@ export default function RegisterPage() {
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer }
}))
}
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
},
];
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
setStatus("Creating account...");
setIsError(false);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
body: JSON.stringify({ email, password }),
});
const payload = (await res.json()) as ApiResponse<AuthData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Registration failed.");
setIsError(true);
return;
}
setStatus(`Welcome, ${payload.data.user.email}`);
localStorage.setItem("ledgerone_token", payload.data.token);
localStorage.setItem("ledgerone_user_id", payload.data.user.id);
router.push("/login");
storeAuthTokens({
accessToken: payload.data.accessToken,
refreshToken: payload.data.refreshToken,
user: payload.data.user,
});
setStatus("Account created! Please verify your email.");
router.push("/app");
} catch {
setStatus("Registration failed.");
setStatus("Registration failed. Please try again.");
setIsError(true);
}
};
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
@ -90,7 +99,6 @@ export default function RegisterPage() {
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<form className="space-y-6" onSubmit={onSubmit}>
@ -100,43 +108,30 @@ export default function RegisterPage() {
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
id="password" name="password" type="password" autoComplete="new-password" required
minLength={8}
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
</div>
<div>
<div className="flex items-center gap-2 mb-6">
<input
id="terms"
name="terms"
type="checkbox"
required
id="terms" name="terms" type="checkbox" required
className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary"
onChange={(e) => {
const btn = document.getElementById("submit-btn") as HTMLButtonElement;
@ -144,34 +139,28 @@ export default function RegisterPage() {
}}
/>
<label htmlFor="terms" className="text-sm text-muted-foreground">
I agree to the <Link href="/terms" className="text-primary hover:underline">Terms of Service</Link> and <Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
I agree to the{" "}
<Link href="/terms" className="text-primary hover:underline">Terms of Service</Link>{" "}
and{" "}
<Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
</label>
</div>
<button
id="submit-btn"
type="submit"
disabled
id="submit-btn" type="submit" disabled
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
>
Create account
</button>
</div>
</form>
{status && (
<div className="mt-4 rounded-lg bg-accent/10 border border-accent/20 p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-accent-foreground">{status}</h3>
</div>
</div>
<div className={`mt-4 rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
<p className="text-sm font-medium text-center">{status}</p>
</div>
)}
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>

147
app/reset-password/page.tsx Normal file
View File

@ -0,0 +1,147 @@
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (password !== confirm) {
setStatus("Passwords do not match.");
setIsError(true);
return;
}
if (password.length < 8) {
setStatus("Password must be at least 8 characters.");
setIsError(true);
return;
}
if (!token) {
setStatus("Missing reset token. Please use the link from your email.");
setIsError(true);
return;
}
setStatus("Resetting password...");
setIsError(false);
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const payload = (await res.json()) as ApiResponse<{ message: string }>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Reset failed. The link may have expired.");
setIsError(true);
return;
}
setStatus("Password reset successfully! Redirecting to sign in...");
setTimeout(() => router.push("/login"), 2000);
} catch {
setStatus("Something went wrong. Please try again.");
setIsError(true);
}
};
if (!token) {
return (
<div className="text-center space-y-4">
<p className="text-sm text-muted-foreground">Invalid reset link. Please request a new one.</p>
<Link href="/forgot-password" className="text-primary hover:underline text-sm">Request password reset</Link>
</div>
);
}
return (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
New Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="new-password" required minLength={8}
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-medium text-foreground">
Confirm Password
</label>
<div className="mt-1">
<input
id="confirm" name="confirm" type="password" autoComplete="new-password" required
value={confirm} onChange={(e) => setConfirm(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Reset Password
</button>
{status && (
<div className={`rounded-lg p-4 ${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}`}>
<p className="text-sm text-center">{status}</p>
</div>
)}
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Set new password
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Back to sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
</div>
);
}

220
app/settings/2fa/page.tsx Normal file
View File

@ -0,0 +1,220 @@
"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type TwoFaGenerateData = { qrCode: string; otpAuthUrl: string };
type UserData = { user: { twoFactorEnabled: boolean } };
export default function TwoFAPage() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [qrCode, setQrCode] = useState<string>("");
const [otpAuthUrl, setOtpAuthUrl] = useState<string>("");
const [token, setToken] = useState("");
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const [step, setStep] = useState<"idle" | "scan" | "done">("idle");
useEffect(() => {
apiFetch<UserData["user"]>("/api/auth/me")
.then((res) => {
if (!res.error && res.data) {
// me returns { user: {...} }
const data = res.data as unknown as UserData;
setEnabled(data.user?.twoFactorEnabled ?? false);
}
})
.catch(() => {});
}, []);
const handleGenerate = async () => {
setStatus("Generating QR code...");
setIsError(false);
const res = await apiFetch<TwoFaGenerateData>("/api/2fa/generate", { method: "POST" });
if (res.error) {
setStatus(res.error.message ?? "Failed to generate 2FA secret.");
setIsError(true);
return;
}
setQrCode(res.data.qrCode);
setOtpAuthUrl(res.data.otpAuthUrl);
setStep("scan");
setStatus("Scan the QR code with your authenticator app, then enter the code below.");
};
const handleEnable = async (event: React.FormEvent) => {
event.preventDefault();
if (!token || token.length !== 6) {
setStatus("Please enter the 6-digit code.");
setIsError(true);
return;
}
setStatus("Verifying...");
setIsError(false);
const res = await apiFetch<{ message: string }>("/api/2fa/enable", {
method: "POST",
body: JSON.stringify({ token }),
});
if (res.error) {
setStatus(res.error.message ?? "Verification failed. Try again.");
setIsError(true);
return;
}
setEnabled(true);
setStep("done");
setStatus("Two-factor authentication is now active.");
};
const handleDisable = async (event: React.FormEvent) => {
event.preventDefault();
if (!token || token.length !== 6) {
setStatus("Please enter the 6-digit code to confirm.");
setIsError(true);
return;
}
setStatus("Disabling 2FA...");
setIsError(false);
const res = await apiFetch<{ message: string }>("/api/2fa/disable", {
method: "DELETE",
body: JSON.stringify({ token }),
});
if (res.error) {
setStatus(res.error.message ?? "Failed. Check your authenticator code.");
setIsError(true);
return;
}
setEnabled(false);
setToken("");
setStep("idle");
setStatus("Two-factor authentication has been disabled.");
};
return (
<AppShell title="Two-Factor Auth" subtitle="Secure your account with a TOTP authenticator.">
<div className="max-w-lg">
<div className="glass-panel rounded-2xl p-8">
{enabled === null ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
) : enabled ? (
<>
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">2FA is Active</p>
<p className="text-xs text-muted-foreground">Your account is protected with TOTP.</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
To disable two-factor authentication, enter the current code from your authenticator app.
</p>
<form onSubmit={handleDisable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Authenticator Code</label>
<input
type="text" inputMode="numeric" maxLength={6} placeholder="000000"
value={token} onChange={(e) => setToken(e.target.value.replace(/\D/g, ""))}
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
required
/>
</div>
<button
type="submit"
className="w-full rounded-lg border border-red-500/30 bg-red-500/10 py-2.5 px-4 text-sm font-bold text-red-500 hover:bg-red-500/20 transition-all"
>
Disable 2FA
</button>
</form>
</>
) : step === "idle" ? (
<>
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center">
<svg className="h-5 w-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">2FA Not Enabled</p>
<p className="text-xs text-muted-foreground">Add an extra layer of protection.</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
Use any TOTP authenticator app (Google Authenticator, Authy, 1Password) to generate login codes.
</p>
<button
onClick={handleGenerate}
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Enable Two-Factor Auth
</button>
</>
) : step === "scan" ? (
<>
<p className="text-sm font-bold text-foreground mb-2">Scan this QR code</p>
<p className="text-xs text-muted-foreground mb-4">
Open your authenticator app and scan the code below, or enter the key manually.
</p>
{qrCode && (
<div className="flex justify-center mb-4 bg-white p-3 rounded-xl inline-block">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCode} alt="2FA QR Code" className="h-40 w-40" />
</div>
)}
{otpAuthUrl && (
<p className="text-[10px] text-muted-foreground break-all mb-4 font-mono bg-secondary/30 p-2 rounded">{otpAuthUrl}</p>
)}
<form onSubmit={handleEnable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Enter code to confirm</label>
<input
type="text" inputMode="numeric" maxLength={6} placeholder="6-digit code"
value={token} onChange={(e) => setToken(e.target.value.replace(/\D/g, ""))}
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
required autoFocus
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Verify and Enable
</button>
</form>
</>
) : (
<div className="text-center py-4">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-bold text-foreground">2FA Enabled Successfully</p>
<p className="text-xs text-muted-foreground mt-1">Your account now requires a code on each login.</p>
</div>
)}
{status && (
<div className={`mt-4 rounded-lg p-3 text-sm text-center ${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}`}>
{status}
</div>
)}
</div>
</div>
</AppShell>
);
}

View File

@ -5,19 +5,24 @@ const settingsItems = [
{
title: "Profile",
description: "Update company details, contact info, and onboarding fields.",
href: "/settings/profile"
href: "/settings/profile",
},
{
title: "Two-Factor Auth",
description: "Add a TOTP authenticator app for extra security.",
href: "/settings/2fa",
},
{
title: "Subscription",
description: "View plan details, upgrade options, and billing cadence.",
href: "/settings/subscription"
}
href: "/settings/subscription",
},
];
export default function SettingsPage() {
return (
<AppShell title="Settings" subtitle="Account preferences and plan configuration.">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{settingsItems.map((item) => (
<Link
key={item.title}

View File

@ -1,3 +1,149 @@
import ProfilePage from "../../profile/page";
"use client";
export default ProfilePage;
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ProfileData = {
user: {
id: string;
email: string;
fullName?: string | null;
phone?: string | null;
companyName?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
};
};
export default function ProfilePage() {
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState("");
const [companyName, setCompanyName] = useState("");
const [addressLine1, setAddressLine1] = useState("");
const [addressLine2, setAddressLine2] = useState("");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [postalCode, setPostalCode] = useState("");
const [country, setCountry] = useState("");
useEffect(() => {
apiFetch<ProfileData>("/api/auth/me")
.then((res) => {
if (!res.error && res.data) {
const u = (res.data as unknown as ProfileData).user;
if (u) {
setFullName(u.fullName ?? "");
setPhone(u.phone ?? "");
setCompanyName(u.companyName ?? "");
setAddressLine1(u.addressLine1 ?? "");
setAddressLine2(u.addressLine2 ?? "");
setCity(u.city ?? "");
setState(u.state ?? "");
setPostalCode(u.postalCode ?? "");
setCountry(u.country ?? "");
}
}
})
.catch(() => {});
}, []);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Saving profile...");
setIsError(false);
const res = await apiFetch<ProfileData>("/api/auth/profile", {
method: "PATCH",
body: JSON.stringify({
fullName: fullName || undefined,
phone: phone || undefined,
companyName: companyName || undefined,
addressLine1: addressLine1 || undefined,
addressLine2: addressLine2 || undefined,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
}),
});
if (res.error) {
setStatus(res.error.message ?? "Profile update failed.");
setIsError(true);
return;
}
setStatus("Profile saved successfully.");
};
const inputCls = "w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-primary transition-all";
const labelCls = "block text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wide";
return (
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
<div className="max-w-2xl">
<div className="glass-panel p-8 rounded-2xl">
<h2 className="text-xl font-bold text-foreground">Personal & Business Details</h2>
<p className="mt-1 text-sm text-muted-foreground">
These details appear on tax exports and CSV reports.
</p>
<form className="mt-6 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
<div className="md:col-span-2">
<label className={labelCls}>Full name</label>
<input className={inputCls} type="text" value={fullName} onChange={(e) => setFullName(e.target.value)} required />
</div>
<div>
<label className={labelCls}>Phone</label>
<input className={inputCls} type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div>
<label className={labelCls}>Company</label>
<input className={inputCls} type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Address line 1</label>
<input className={inputCls} type="text" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Address line 2</label>
<input className={inputCls} type="text" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
</div>
<div>
<label className={labelCls}>City</label>
<input className={inputCls} type="text" value={city} onChange={(e) => setCity(e.target.value)} />
</div>
<div>
<label className={labelCls}>State</label>
<input className={inputCls} type="text" value={state} onChange={(e) => setState(e.target.value)} />
</div>
<div>
<label className={labelCls}>Postal code</label>
<input className={inputCls} type="text" value={postalCode} onChange={(e) => setPostalCode(e.target.value)} />
</div>
<div>
<label className={labelCls}>Country</label>
<input className={inputCls} type="text" value={country} onChange={(e) => setCountry(e.target.value)} />
</div>
<div className="md:col-span-2">
<button
type="submit"
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Save profile
</button>
</div>
</form>
{status && (
<div className={`mt-4 rounded-lg p-3 text-sm text-center ${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}`}>
{status}
</div>
)}
</div>
</div>
</AppShell>
);
}

View File

@ -1,91 +1,164 @@
import { AppShell } from "../../../components/app-shell";
"use client";
const plans = [
{
name: "Free",
price: "$0",
cadence: "forever",
highlight: "Connect up to 2 accounts",
features: [
"2 connected accounts",
"30-day transaction history",
"Basic exports",
"Email support"
]
},
{
name: "Pro Monthly",
price: "$9",
cadence: "per month",
highlight: "Unlimited connected accounts",
features: [
"Unlimited accounts",
"12-month history",
"Advanced exports + rules",
"Priority support"
]
},
{
name: "Pro Annual",
price: "$90",
cadence: "per year",
highlight: "Two months free",
features: [
"Unlimited accounts",
"12-month history",
"Advanced exports + rules",
"Priority support"
]
}
];
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "@/lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type SubscriptionData = {
plan?: string;
status?: string;
billingCycleAnchor?: number;
cancelAtPeriodEnd?: boolean;
};
const PLAN_LABELS: Record<string, string> = {
free: "Free",
pro: "Pro",
elite: "Elite",
};
const PLAN_DESCRIPTIONS: Record<string, string> = {
free: "Up to 2 accounts, basic CSV export, 30-day history.",
pro: "Unlimited accounts, Google Sheets, 24-month history, priority support.",
elite: "Everything in Pro + tax return module, AI rule suggestions, dedicated support.",
};
export default function SubscriptionPage() {
return (
<AppShell title="Subscription" subtitle="Choose a plan that fits your team.">
<div className="app-card p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted">Current plan</p>
<h2 className="mt-2 text-2xl font-semibold">Free Plan</h2>
<p className="mt-2 text-sm text-muted">
Free includes up to two connected accounts. Upgrade any time to unlock
unlimited accounts and advanced automation.
</p>
</div>
<button
type="button"
className="app-button rounded-full px-5 py-2 text-xs font-semibold"
>
Start free trial
</button>
</div>
</div>
const [sub, setSub] = useState<SubscriptionData | null>(null);
const [loading, setLoading] = useState(true);
const [actionStatus, setActionStatus] = useState("");
const [actionLoading, setActionLoading] = useState(false);
<div className="mt-6 grid gap-4 lg:grid-cols-3">
{plans.map((plan) => (
<div key={plan.name} className="app-card p-6">
<p className="text-xs uppercase tracking-[0.3em] text-muted">{plan.name}</p>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-3xl font-semibold">{plan.price}</span>
<span className="text-xs text-muted">{plan.cadence}</span>
</div>
<p className="mt-2 text-sm text-muted">{plan.highlight}</p>
<ul className="mt-4 space-y-2 text-xs text-muted">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
{feature}
</li>
))}
</ul>
useEffect(() => {
apiFetch<SubscriptionData>("/api/stripe/subscription")
.then((res) => {
if (!res.error) setSub(res.data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleUpgrade = async (plan: string) => {
setActionLoading(true);
setActionStatus("Redirecting to checkout...");
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/checkout", {
method: "POST",
body: JSON.stringify({
plan,
successUrl: `${appUrl}/settings/subscription?upgraded=1`,
cancelUrl: `${appUrl}/settings/subscription`,
}),
});
setActionLoading(false);
if (res.error || !res.data?.url) {
setActionStatus(res.error?.message ?? "Could not start checkout. Check Stripe configuration.");
return;
}
window.location.href = res.data.url;
};
const handlePortal = async () => {
setActionLoading(true);
setActionStatus("Redirecting to billing portal...");
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/portal", {
method: "POST",
body: JSON.stringify({ returnUrl: `${appUrl}/settings/subscription` }),
});
setActionLoading(false);
if (res.error || !res.data?.url) {
setActionStatus(res.error?.message ?? "Could not open billing portal. Check Stripe configuration.");
return;
}
window.location.href = res.data.url;
};
const currentPlan = sub?.plan ?? "free";
const planLabel = PLAN_LABELS[currentPlan] ?? currentPlan;
const planDesc = PLAN_DESCRIPTIONS[currentPlan] ?? "";
return (
<AppShell title="Subscription" subtitle="Manage your plan and billing details.">
<div className="max-w-2xl space-y-6">
{/* Current plan card */}
<div className="glass-panel rounded-2xl p-8">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Current Plan</p>
{loading ? (
<div className="mt-4 h-8 w-32 bg-secondary/60 rounded animate-pulse" />
) : (
<>
<div className="mt-3 flex items-center gap-3">
<span className="text-3xl font-bold text-foreground">{planLabel}</span>
{sub?.status && sub.status !== "active" && sub.status !== "free" && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-yellow-500/10 text-yellow-500 capitalize">
{sub.status}
</span>
)}
{(sub?.status === "active" || currentPlan !== "free") && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Active</span>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground">{planDesc}</p>
{sub?.cancelAtPeriodEnd && (
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
)}
</>
)}
{!loading && currentPlan !== "free" && (
<button
type="button"
className="mt-6 w-full rounded-full border border-ink/10 bg-white/5 px-4 py-2 text-xs font-semibold text-ink"
onClick={handlePortal}
disabled={actionLoading}
className="mt-6 rounded-lg border border-border bg-secondary/30 py-2 px-4 text-sm font-medium text-foreground hover:bg-secondary/60 transition-all disabled:opacity-50"
>
{plan.name === "Free" ? "Current plan" : "Upgrade"}
Manage Billing
</button>
)}
</div>
{/* Upgrade options */}
{currentPlan === "free" && (
<div className="grid gap-4 md:grid-cols-2">
{(["pro", "elite"] as const).map((plan) => (
<div key={plan} className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
<p className="text-lg font-bold text-foreground capitalize">{plan}</p>
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p>
<button
onClick={() => handleUpgrade(plan)}
disabled={actionLoading}
className="mt-4 w-full rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
>
Upgrade to {PLAN_LABELS[plan]}
</button>
</div>
))}
</div>
)}
{currentPlan === "pro" && (
<div className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
<p className="text-lg font-bold text-foreground">Elite</p>
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS.elite}</p>
<button
onClick={() => handleUpgrade("elite")}
disabled={actionLoading}
className="mt-4 rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
>
Upgrade to Elite
</button>
</div>
))}
)}
{actionStatus && (
<p className="text-sm text-muted-foreground">{actionStatus}</p>
)}
</div>
</AppShell>
);

File diff suppressed because it is too large Load Diff

118
app/verify-email/page.tsx Normal file
View File

@ -0,0 +1,118 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState, Suspense } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
function VerifyEmailContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("No verification token provided.");
return;
}
fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
.then((res) => res.json() as Promise<ApiResponse<{ message: string }>>)
.then((payload) => {
if (payload.error) {
setStatus("error");
setMessage(payload.error.message ?? "Verification failed.");
} else {
setStatus("success");
setMessage(payload.data?.message ?? "Email verified successfully.");
}
})
.catch(() => {
setStatus("error");
setMessage("Something went wrong. Please try again.");
});
}, [token]);
return (
<div className="text-center space-y-4">
{status === "loading" && (
<>
<div className="mx-auto h-12 w-12 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<p className="text-sm text-muted-foreground">Verifying your email...</p>
</>
)}
{status === "success" && (
<>
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-lg font-semibold text-foreground">Email Verified!</p>
<p className="text-sm text-muted-foreground">{message}</p>
<Link
href="/login"
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Sign in
</Link>
</>
)}
{status === "error" && (
<>
<div className="mx-auto h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-lg font-semibold text-foreground">Verification Failed</p>
<p className="text-sm text-muted-foreground">{message}</p>
<p className="text-xs text-muted-foreground">The link may have expired. Please register again or contact support.</p>
<Link
href="/login"
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Back to sign in
</Link>
</>
)}
</div>
);
}
export default function VerifyEmailPage() {
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Email Verification
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<VerifyEmailContent />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
</div>
);
}

View File

@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { CurrencyToggle, MoodToggle } from "./currency-toggle";
import { apiFetch, clearAuth, getStoredUser } from "@/lib/api";
const navItems = [
{ href: "/app", label: "Dashboard" },
@ -12,28 +13,107 @@ const navItems = [
{ href: "/rules", label: "Rules" },
{ href: "/exports", label: "Exports" },
{ href: "/tax", label: "Tax" },
{ href: "/settings", label: "Settings" }
{ href: "/settings", label: "Settings" },
];
type User = {
id: string;
email: string;
fullName?: string | null;
emailVerified?: boolean;
twoFactorEnabled?: boolean;
};
type SubscriptionData = {
plan?: string;
status?: string;
};
type AppShellProps = {
title: string;
subtitle?: string;
children: React.ReactNode;
};
function initials(user: User): string {
if (user.fullName) {
const parts = user.fullName.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return parts[0].slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
}
function displayName(user: User): string {
return user.fullName?.trim() || user.email;
}
export function AppShell({ title, subtitle, children }: AppShellProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [user, setUser] = useState<User | null>(getStoredUser<User>());
const [planLabel, setPlanLabel] = useState("Free Plan");
const onLogout = () => {
localStorage.removeItem("ledgerone_token");
localStorage.removeItem("ledgerone_user_id");
useEffect(() => {
// Fetch latest user profile
apiFetch<{ user: User }>("/api/auth/me")
.then((res) => {
if (!res.error && res.data?.user) {
setUser(res.data.user);
}
})
.catch(() => {});
// Fetch subscription plan
apiFetch<SubscriptionData>("/api/stripe/subscription")
.then((res) => {
if (!res.error && res.data?.plan) {
const p = res.data.plan;
setPlanLabel(p.charAt(0).toUpperCase() + p.slice(1) + " Plan");
}
})
.catch(() => {});
}, []);
const onLogout = async () => {
const refreshToken = localStorage.getItem("ledgerone_refresh_token") ?? "";
try {
await fetch("/api/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
} catch {}
clearAuth();
router.push("/login");
};
const NavLinks = () => (
<>
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/app" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}`}
>
<span>{item.label}</span>
</Link>
);
})}
</>
);
return (
<div className="min-h-screen bg-background flex font-sans text-foreground">
{/* Sidebar */}
{/* Desktop Sidebar */}
<aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex">
<div className="h-16 flex items-center px-6 border-b border-border">
<div className="flex items-center gap-2">
@ -48,32 +128,65 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
<div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div>
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}`}
>
{/* Icons could be added here */}
<span>{item.label}</span>
</Link>
);
})}
<NavLinks />
</div>
<div className="mt-auto p-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">
AC
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
{user ? initials(user) : "?"}
</div>
<div className="overflow-hidden">
<p className="text-sm font-medium text-foreground truncate">Alex Chen</p>
<p className="text-xs text-muted-foreground truncate">Pro Plan</p>
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
</div>
</div>
</div>
</aside>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile Sidebar drawer */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border flex flex-col transform transition-transform duration-200 lg:hidden ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="h-16 flex items-center justify-between px-6 border-b border-border">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
L1
</div>
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
</div>
<button onClick={() => setMobileOpen(false)} className="text-muted-foreground hover:text-foreground">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4 space-y-1 flex-1">
<div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div>
<NavLinks />
</div>
<div className="p-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
{user ? initials(user) : "?"}
</div>
<div className="overflow-hidden">
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
</div>
</div>
</div>
@ -81,31 +194,46 @@ export function AppShell({ title, subtitle, children }: AppShellProps) {
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0">
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-6 lg:px-8">
<div>
{/* Breadcrumbs or Title */}
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-4 lg:px-8">
<div className="flex items-center gap-3">
{/* Hamburger — mobile only */}
<button
className="lg:hidden text-muted-foreground hover:text-foreground"
onClick={() => setMobileOpen(true)}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div>
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 lg:gap-4">
<div className="relative hidden md:block">
<input
type="text"
placeholder="Search..."
className="w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
className="w-48 lg:w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
/>
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<CurrencyToggle />
<MoodToggle />
<button onClick={onLogout} className="text-sm font-medium text-muted-foreground hover:text-foreground">
<button
onClick={onLogout}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Log out
</button>
</div>
</header>
<main className="flex-1 p-6 lg:p-8 overflow-y-auto">
<main className="flex-1 p-4 lg:p-8 overflow-y-auto">
<div className="max-w-6xl mx-auto">
{children}
</div>

115
lib/api.ts Normal file
View File

@ -0,0 +1,115 @@
// Client-side fetch helper with automatic Bearer token injection and 401 auto-refresh.
export interface ApiResponse<T = unknown> {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
}
const TOKEN_KEY = "ledgerone_token";
const REFRESH_KEY = "ledgerone_refresh_token";
const USER_KEY = "ledgerone_user";
export function getStoredToken(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem(TOKEN_KEY) ?? "";
}
export function getStoredUser<T = unknown>(): T | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(USER_KEY);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
export function storeAuthTokens(data: {
accessToken: string;
refreshToken: string;
user: unknown;
}): void {
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_KEY, data.refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
// Set a non-HttpOnly cookie so Next.js middleware can detect auth state
document.cookie = "ledgerone_auth=1; path=/; max-age=2592000; SameSite=Lax";
}
export function clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USER_KEY);
document.cookie = "ledgerone_auth=; path=/; max-age=0";
}
async function tryRefresh(): Promise<string | null> {
const refreshToken = localStorage.getItem(REFRESH_KEY);
if (!refreshToken) return null;
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
clearAuth();
return null;
}
const payload = (await res.json()) as ApiResponse<{
accessToken: string;
refreshToken: string;
}>;
if (payload.error || !payload.data?.accessToken) {
clearAuth();
return null;
}
localStorage.setItem(TOKEN_KEY, payload.data.accessToken);
localStorage.setItem(REFRESH_KEY, payload.data.refreshToken);
return payload.data.accessToken;
} catch {
clearAuth();
return null;
}
}
export async function apiFetch<T = unknown>(
path: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const token = getStoredToken();
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
if (
options.body &&
typeof options.body === "string" &&
!headers["Content-Type"]
) {
headers["Content-Type"] = "application/json";
}
let res = await fetch(path, { ...options, headers });
// Auto-refresh on 401
if (res.status === 401) {
const newToken = await tryRefresh();
if (newToken) {
headers["Authorization"] = `Bearer ${newToken}`;
res = await fetch(path, { ...options, headers });
} else {
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return {
data: null as T,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Session expired. Please sign in again." },
};
}
}
return res.json() as Promise<ApiResponse<T>>;
}

73
lib/backend.ts Normal file
View File

@ -0,0 +1,73 @@
// Server-side proxy helper for Next.js API routes.
// Forwards requests to the NestJS backend, including the Bearer token.
import { NextRequest, NextResponse } from "next/server";
const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
export function getBackendUrl(path: string): string {
return `${BASE_URL}/api/${path.replace(/^\//, "")}`;
}
interface ProxyOptions {
method?: string;
body?: BodyInit | null;
extraHeaders?: Record<string, string>;
search?: string;
}
export async function proxyRequest(
req: NextRequest,
backendPath: string,
options: ProxyOptions = {}
): Promise<NextResponse> {
const url = new URL(req.url);
const search = options.search !== undefined ? options.search : url.search;
const targetUrl = `${getBackendUrl(backendPath)}${search}`;
const method = options.method ?? req.method;
const auth = req.headers.get("authorization") ?? "";
const contentType = req.headers.get("content-type") ?? "";
const headers: Record<string, string> = { ...options.extraHeaders };
if (auth) headers["Authorization"] = auth;
let body: BodyInit | null | undefined = undefined;
if (method !== "GET" && method !== "HEAD") {
if (options.body !== undefined) {
body = options.body;
if (contentType) headers["Content-Type"] = contentType;
} else if (contentType.includes("multipart/form-data")) {
body = await req.formData();
// Do not set Content-Type — fetch sets it with boundary automatically
} else {
body = await req.text();
if (contentType) headers["Content-Type"] = contentType;
}
}
try {
const res = await fetch(targetUrl, {
method,
headers,
body: body ?? undefined,
});
const payload = await res.text();
return new NextResponse(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json",
},
});
} catch {
return NextResponse.json(
{
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." },
},
{ status: 503 }
);
}
}

38
middleware.ts Normal file
View File

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
const PROTECTED_PREFIXES = [
"/app",
"/transactions",
"/rules",
"/exports",
"/tax",
"/settings",
"/profile",
];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const isProtected = PROTECTED_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
if (!isProtected) return NextResponse.next();
// ledgerone_auth cookie is set by storeAuthTokens() in lib/api.ts
const authCookie = req.cookies.get("ledgerone_auth");
if (!authCookie?.value) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
// Run on all routes except Next.js internals and static files
"/((?!_next/static|_next/image|favicon\.ico|api/).*)",
],
};

1
package-lock.json generated
View File

@ -1433,6 +1433,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View File

@ -21,7 +21,10 @@
{
"name": "next"
}
]
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",

240
write-frontend-1-lib.mjs Normal file
View File

@ -0,0 +1,240 @@
import { writeFileSync, mkdirSync } from "fs";
mkdirSync("lib", { recursive: true });
// ─── lib/api.ts ─────────────────────────────────────────────────────────────
writeFileSync("lib/api.ts", `// Client-side fetch helper with automatic Bearer token injection and 401 auto-refresh.
export interface ApiResponse<T = unknown> {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
}
const TOKEN_KEY = "ledgerone_token";
const REFRESH_KEY = "ledgerone_refresh_token";
const USER_KEY = "ledgerone_user";
export function getStoredToken(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem(TOKEN_KEY) ?? "";
}
export function getStoredUser<T = unknown>(): T | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(USER_KEY);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
export function storeAuthTokens(data: {
accessToken: string;
refreshToken: string;
user: unknown;
}): void {
localStorage.setItem(TOKEN_KEY, data.accessToken);
localStorage.setItem(REFRESH_KEY, data.refreshToken);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
// Set a non-HttpOnly cookie so Next.js middleware can detect auth state
document.cookie = "ledgerone_auth=1; path=/; max-age=2592000; SameSite=Lax";
}
export function clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(USER_KEY);
document.cookie = "ledgerone_auth=; path=/; max-age=0";
}
async function tryRefresh(): Promise<string | null> {
const refreshToken = localStorage.getItem(REFRESH_KEY);
if (!refreshToken) return null;
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
clearAuth();
return null;
}
const payload = (await res.json()) as ApiResponse<{
accessToken: string;
refreshToken: string;
}>;
if (payload.error || !payload.data?.accessToken) {
clearAuth();
return null;
}
localStorage.setItem(TOKEN_KEY, payload.data.accessToken);
localStorage.setItem(REFRESH_KEY, payload.data.refreshToken);
return payload.data.accessToken;
} catch {
clearAuth();
return null;
}
}
export async function apiFetch<T = unknown>(
path: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const token = getStoredToken();
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = \`Bearer \${token}\`;
if (
options.body &&
typeof options.body === "string" &&
!headers["Content-Type"]
) {
headers["Content-Type"] = "application/json";
}
let res = await fetch(path, { ...options, headers });
// Auto-refresh on 401
if (res.status === 401) {
const newToken = await tryRefresh();
if (newToken) {
headers["Authorization"] = \`Bearer \${newToken}\`;
res = await fetch(path, { ...options, headers });
} else {
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return {
data: null as T,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Session expired. Please sign in again." },
};
}
}
return res.json() as Promise<ApiResponse<T>>;
}
`);
// ─── lib/backend.ts ──────────────────────────────────────────────────────────
writeFileSync("lib/backend.ts", `// Server-side proxy helper for Next.js API routes.
// Forwards requests to the NestJS backend, including the Bearer token.
import { NextRequest, NextResponse } from "next/server";
const BASE_URL = process.env.LEDGERONE_API_URL ?? "http://localhost:3051";
export function getBackendUrl(path: string): string {
return \`\${BASE_URL}/api/\${path.replace(/^\\//, "")}\`;
}
interface ProxyOptions {
method?: string;
body?: BodyInit | null;
extraHeaders?: Record<string, string>;
search?: string;
}
export async function proxyRequest(
req: NextRequest,
backendPath: string,
options: ProxyOptions = {}
): Promise<NextResponse> {
const url = new URL(req.url);
const search = options.search !== undefined ? options.search : url.search;
const targetUrl = \`\${getBackendUrl(backendPath)}\${search}\`;
const method = options.method ?? req.method;
const auth = req.headers.get("authorization") ?? "";
const contentType = req.headers.get("content-type") ?? "";
const headers: Record<string, string> = { ...options.extraHeaders };
if (auth) headers["Authorization"] = auth;
let body: BodyInit | null | undefined = undefined;
if (method !== "GET" && method !== "HEAD") {
if (options.body !== undefined) {
body = options.body;
if (contentType) headers["Content-Type"] = contentType;
} else if (contentType.includes("multipart/form-data")) {
body = await req.formData();
// Do not set Content-Type — fetch sets it with boundary automatically
} else {
body = await req.text();
if (contentType) headers["Content-Type"] = contentType;
}
}
try {
const res = await fetch(targetUrl, {
method,
headers,
body: body ?? undefined,
});
const payload = await res.text();
return new NextResponse(payload, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") ?? "application/json",
},
});
} catch {
return NextResponse.json(
{
data: null,
meta: { timestamp: new Date().toISOString(), version: "v1" },
error: { message: "Backend unavailable." },
},
{ status: 503 }
);
}
}
`);
// ─── middleware.ts ───────────────────────────────────────────────────────────
writeFileSync("middleware.ts", `import { NextRequest, NextResponse } from "next/server";
const PROTECTED_PREFIXES = [
"/app",
"/transactions",
"/rules",
"/exports",
"/tax",
"/settings",
"/profile",
];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const isProtected = PROTECTED_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(\`\${prefix}/\`)
);
if (!isProtected) return NextResponse.next();
// ledgerone_auth cookie is set by storeAuthTokens() in lib/api.ts
const authCookie = req.cookies.get("ledgerone_auth");
if (!authCookie?.value) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
// Run on all routes except Next.js internals and static files
"/((?!_next/static|_next/image|favicon\\.ico|api/).*)",
],
};
`);
console.log("✅ lib/api.ts, lib/backend.ts, middleware.ts written");

764
write-frontend-2-auth.mjs Normal file
View File

@ -0,0 +1,764 @@
import { writeFileSync, mkdirSync } from "fs";
// ─── Updated login/page.tsx ──────────────────────────────────────────────────
writeFileSync("app/login/page.tsx", `"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
import { storeAuthTokens } from "../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type AuthData = {
user: { id: string; email: string; fullName?: string; emailVerified?: boolean };
accessToken: string;
refreshToken: string;
requiresTwoFactor?: boolean;
};
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const nextPath = searchParams.get("next") ?? "/app";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totpToken, setTotpToken] = useState("");
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
const [status, setStatus] = useState<string>("");
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Signing in...");
setIsError(false);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, ...(requiresTwoFactor ? { totpToken } : {}) }),
});
const payload = (await res.json()) as ApiResponse<AuthData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Login failed.");
setIsError(true);
return;
}
if (payload.data.requiresTwoFactor) {
setRequiresTwoFactor(true);
setStatus("Enter the code from your authenticator app.");
return;
}
storeAuthTokens({
accessToken: payload.data.accessToken,
refreshToken: payload.data.refreshToken,
user: payload.data.user,
});
setStatus(\`Welcome back, \${payload.data.user.email}\`);
router.push(nextPath);
} catch {
setStatus("Login failed. Please try again.");
setIsError(true);
}
};
return (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<div className="mt-1">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="current-password" required
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
{requiresTwoFactor && (
<div>
<label htmlFor="totp" className="block text-sm font-medium text-foreground">
Authenticator Code
</label>
<div className="mt-1">
<input
id="totp" name="totp" type="text" inputMode="numeric" maxLength={6}
placeholder="6-digit code" autoComplete="one-time-code" required
value={totpToken} onChange={(e) => setTotpToken(e.target.value.replace(/\\D/g, ""))}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all tracking-widest text-center"
/>
</div>
</div>
)}
<div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
{requiresTwoFactor ? "Verify" : "Sign in"}
</button>
</div>
<div className="text-center">
<Link href="/forgot-password" className="text-sm text-muted-foreground hover:text-primary transition-colors">
Forgot your password?
</Link>
</div>
{status && (
<div className={\`mt-4 rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
<p className="text-sm font-medium text-center">{status}</p>
</div>
)}
</form>
);
}
export default function LoginPage() {
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Login",
description: "Sign in to LedgerOne to access your audit-ready ledger.",
url: \`\${siteInfo.url}/login\`,
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
},
];
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Or{" "}
<Link href="/register" className="font-medium text-primary hover:text-primary/80 transition-colors">
start your 14-day free trial
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
`);
// ─── Updated register/page.tsx ───────────────────────────────────────────────
writeFileSync("app/register/page.tsx", `"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, FormEvent } from "react";
import { PageSchema } from "../../components/page-schema";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
import { defaultFaqs } from "../../data/faq";
import { siteInfo } from "../../data/site";
import { storeAuthTokens } from "../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type AuthData = {
user: { id: string; email: string; fullName?: string };
accessToken: string;
refreshToken: string;
message?: string;
};
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState<string>("");
const [isError, setIsError] = useState(false);
const schema = [
{
"@context": "https://schema.org",
"@type": "WebPage",
name: "LedgerOne Create Account",
description: "Create a LedgerOne account and start with two free accounts.",
url: \`\${siteInfo.url}/register\`,
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: defaultFaqs.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
},
];
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
setStatus("Creating account...");
setIsError(false);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const payload = (await res.json()) as ApiResponse<AuthData>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Registration failed.");
setIsError(true);
return;
}
storeAuthTokens({
accessToken: payload.data.accessToken,
refreshToken: payload.data.refreshToken,
user: payload.data.user,
});
setStatus("Account created! Please verify your email.");
router.push("/app");
} catch {
setStatus("Registration failed. Please try again.");
setIsError(true);
}
};
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<div className="mt-1">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="new-password" required
minLength={8}
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
</div>
<div>
<div className="flex items-center gap-2 mb-6">
<input
id="terms" name="terms" type="checkbox" required
className="h-4 w-4 rounded border-border bg-background/50 text-primary focus:ring-primary"
onChange={(e) => {
const btn = document.getElementById("submit-btn") as HTMLButtonElement;
if (btn) btn.disabled = !e.target.checked;
}}
/>
<label htmlFor="terms" className="text-sm text-muted-foreground">
I agree to the{" "}
<Link href="/terms" className="text-primary hover:underline">Terms of Service</Link>{" "}
and{" "}
<Link href="/privacy-policy" className="text-primary hover:underline">Privacy Policy</Link>
</label>
</div>
<button
id="submit-btn" type="submit" disabled
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
>
Create account
</button>
</div>
</form>
{status && (
<div className={\`mt-4 rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
<p className="text-sm font-medium text-center">{status}</p>
</div>
)}
</div>
</div>
</div>
<SiteFooter />
<PageSchema schema={schema} />
</div>
);
}
`);
// ─── New forgot-password/page.tsx ────────────────────────────────────────────
mkdirSync("app/forgot-password", { recursive: true });
writeFileSync("app/forgot-password/page.tsx", `"use client";
import Link from "next/link";
import { useState } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("");
const [sent, setSent] = useState(false);
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Sending reset link...");
setIsError(false);
try {
const res = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const payload = (await res.json()) as ApiResponse<{ message: string }>;
if (!res.ok && payload.error) {
setStatus(payload.error.message ?? "Something went wrong.");
setIsError(true);
return;
}
setSent(true);
setStatus(payload.data?.message ?? "If that email exists, a reset link has been sent.");
} catch {
setStatus("Something went wrong. Please try again.");
setIsError(true);
}
};
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Back to sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
{sent ? (
<div className="text-center space-y-4">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<p className="text-sm text-foreground font-medium">{status}</p>
<p className="text-xs text-muted-foreground">Check your email inbox and spam folder.</p>
<Link href="/login" className="inline-flex justify-center rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all">
Back to sign in
</Link>
</div>
) : (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<p className="mt-1 text-xs text-muted-foreground">
We&#39;ll send a reset link to this address if it has an account.
</p>
<div className="mt-2">
<input
id="email" name="email" type="email" autoComplete="email" required
value={email} onChange={(e) => setEmail(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Send reset link
</button>
{isError && status && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-4">
<p className="text-sm text-center">{status}</p>
</div>
)}
</form>
)}
</div>
</div>
</div>
<SiteFooter />
</div>
);
}
`);
// ─── New verify-email/page.tsx ───────────────────────────────────────────────
mkdirSync("app/verify-email", { recursive: true });
writeFileSync("app/verify-email/page.tsx", `"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState, Suspense } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
function VerifyEmailContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("No verification token provided.");
return;
}
fetch(\`/api/auth/verify-email?token=\${encodeURIComponent(token)}\`)
.then((res) => res.json() as Promise<ApiResponse<{ message: string }>>)
.then((payload) => {
if (payload.error) {
setStatus("error");
setMessage(payload.error.message ?? "Verification failed.");
} else {
setStatus("success");
setMessage(payload.data?.message ?? "Email verified successfully.");
}
})
.catch(() => {
setStatus("error");
setMessage("Something went wrong. Please try again.");
});
}, [token]);
return (
<div className="text-center space-y-4">
{status === "loading" && (
<>
<div className="mx-auto h-12 w-12 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<p className="text-sm text-muted-foreground">Verifying your email...</p>
</>
)}
{status === "success" && (
<>
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-lg font-semibold text-foreground">Email Verified!</p>
<p className="text-sm text-muted-foreground">{message}</p>
<Link
href="/login"
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Sign in
</Link>
</>
)}
{status === "error" && (
<>
<div className="mx-auto h-12 w-12 rounded-full bg-red-500/10 flex items-center justify-center">
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-lg font-semibold text-foreground">Verification Failed</p>
<p className="text-sm text-muted-foreground">{message}</p>
<p className="text-xs text-muted-foreground">The link may have expired. Please register again or contact support.</p>
<Link
href="/login"
className="inline-flex justify-center rounded-lg bg-primary py-2 px-6 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Back to sign in
</Link>
</>
)}
</div>
);
}
export default function VerifyEmailPage() {
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Email Verification
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<VerifyEmailContent />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
</div>
);
}
`);
// ─── New reset-password/page.tsx ─────────────────────────────────────────────
mkdirSync("app/reset-password", { recursive: true });
writeFileSync("app/reset-password/page.tsx", `"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { SiteFooter } from "../../components/site-footer";
import { SiteHeader } from "../../components/site-header";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (password !== confirm) {
setStatus("Passwords do not match.");
setIsError(true);
return;
}
if (password.length < 8) {
setStatus("Password must be at least 8 characters.");
setIsError(true);
return;
}
if (!token) {
setStatus("Missing reset token. Please use the link from your email.");
setIsError(true);
return;
}
setStatus("Resetting password...");
setIsError(false);
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const payload = (await res.json()) as ApiResponse<{ message: string }>;
if (!res.ok || payload.error) {
setStatus(payload.error?.message ?? "Reset failed. The link may have expired.");
setIsError(true);
return;
}
setStatus("Password reset successfully! Redirecting to sign in...");
setTimeout(() => router.push("/login"), 2000);
} catch {
setStatus("Something went wrong. Please try again.");
setIsError(true);
}
};
if (!token) {
return (
<div className="text-center space-y-4">
<p className="text-sm text-muted-foreground">Invalid reset link. Please request a new one.</p>
<Link href="/forgot-password" className="text-primary hover:underline text-sm">Request password reset</Link>
</div>
);
}
return (
<form className="space-y-6" onSubmit={onSubmit}>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
New Password
</label>
<div className="mt-1">
<input
id="password" name="password" type="password" autoComplete="new-password" required minLength={8}
value={password} onChange={(e) => setPassword(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">Minimum 8 characters</p>
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-medium text-foreground">
Confirm Password
</label>
<div className="mt-1">
<input
id="confirm" name="confirm" type="password" autoComplete="new-password" required
value={confirm} onChange={(e) => setConfirm(e.target.value)}
className="block w-full appearance-none rounded-lg border border-border bg-background/50 px-3 py-2 placeholder-muted-foreground shadow-sm focus:border-primary focus:outline-none focus:ring-primary sm:text-sm transition-all"
/>
</div>
</div>
<button
type="submit"
className="flex w-full justify-center rounded-lg border border-transparent bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground shadow-sm hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Reset Password
</button>
{status && (
<div className={\`rounded-lg p-4 \${isError ? "bg-red-500/10 border border-red-500/20" : "bg-accent/10 border border-accent/20"}\`}>
<p className="text-sm text-center">{status}</p>
</div>
)}
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen bg-background font-sans text-foreground">
<SiteHeader />
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8 mt-16 relative">
<div className="absolute inset-0 mesh-gradient -z-10 opacity-30" />
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="flex justify-center">
<div className="h-12 w-12 rounded-xl bg-primary flex items-center justify-center text-primary-foreground font-bold text-xl shadow-glow-teal">
L1
</div>
</div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground">
Set new password
</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:text-primary/80 transition-colors">
Back to sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="glass-panel py-8 px-4 shadow-xl sm:rounded-xl sm:px-10">
<Suspense fallback={<div className="text-center text-sm text-muted-foreground">Loading...</div>}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</div>
<SiteFooter />
</div>
);
}
`);
console.log("✅ login, register, forgot-password, verify-email, reset-password pages written");

View File

@ -0,0 +1,370 @@
import { writeFileSync, mkdirSync } from "fs";
// Helper: write a simple proxy route file
function writeProxy(path, content) {
mkdirSync(path.replace(/\/[^/]+$/, ""), { recursive: true });
writeFileSync(path, content);
}
// Reusable proxy template using proxyRequest
const proxyTemplate = (methods, backendPath, opts = "") => {
const lines = [];
lines.push(`import { NextRequest } from "next/server";`);
lines.push(`import { proxyRequest } from "../../../../lib/backend";`);
lines.push(``);
for (const m of methods) {
lines.push(`export async function ${m}(req: NextRequest) {`);
lines.push(` return proxyRequest(req, "${backendPath}"${opts ? `, ${opts}` : ""});`);
lines.push(`}`);
lines.push(``);
}
return lines.join("\n");
};
// ─── Updated existing proxies ────────────────────────────────────────────────
// auth/login — public, no bearer needed
writeProxy("app/api/auth/login/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/login");
}
`);
// auth/register — public
writeProxy("app/api/auth/register/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/register");
}
`);
// auth/profile — needs bearer
writeProxy("app/api/auth/profile/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me");
}
export async function PATCH(req: NextRequest) {
return proxyRequest(req, "auth/profile");
}
`);
// transactions/route.ts
writeProxy("app/api/transactions/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions");
}
`);
// transactions/summary
writeProxy("app/api/transactions/summary/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/summary");
}
`);
// transactions/cashflow
writeProxy("app/api/transactions/cashflow/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/cashflow");
}
`);
// transactions/merchants
writeProxy("app/api/transactions/merchants/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "transactions/merchants");
}
`);
// transactions/sync
writeProxy("app/api/transactions/sync/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/sync");
}
`);
// transactions/manual
writeProxy("app/api/transactions/manual/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/manual");
}
`);
// transactions/[id]/derived
writeProxy("app/api/transactions/[id]/derived/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../../lib/backend";
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, \`transactions/\${params.id}/derived\`);
}
`);
// accounts/route.ts
writeProxy("app/api/accounts/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts");
}
`);
// accounts/link
writeProxy("app/api/accounts/link/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "accounts/link-token");
}
`);
// accounts/manual
writeProxy("app/api/accounts/manual/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "accounts/manual");
}
`);
// rules/route.ts
writeProxy("app/api/rules/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "rules");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "rules");
}
`);
// rules/suggestions
writeProxy("app/api/rules/suggestions/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "rules/suggestions");
}
`);
// exports/csv
writeProxy("app/api/exports/csv/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "exports/csv");
}
`);
// tax/returns
writeProxy("app/api/tax/returns/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "tax/returns");
}
export async function POST(req: NextRequest) {
return proxyRequest(req, "tax/returns");
}
`);
// tax/returns/[id]/export
writeProxy("app/api/tax/returns/[id]/export/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../../lib/backend";
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return proxyRequest(req, \`tax/returns/\${params.id}/export\`);
}
`);
// plaid/link-token
writeProxy("app/api/plaid/link-token/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/link-token");
}
`);
// plaid/exchange
writeProxy("app/api/plaid/exchange/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "plaid/exchange");
}
`);
// ─── New proxy routes ────────────────────────────────────────────────────────
// auth/refresh
mkdirSync("app/api/auth/refresh", { recursive: true });
writeFileSync("app/api/auth/refresh/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/refresh");
}
`);
// auth/logout
mkdirSync("app/api/auth/logout", { recursive: true });
writeFileSync("app/api/auth/logout/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/logout");
}
`);
// auth/me
mkdirSync("app/api/auth/me", { recursive: true });
writeFileSync("app/api/auth/me/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/me");
}
`);
// auth/verify-email
mkdirSync("app/api/auth/verify-email", { recursive: true });
writeFileSync("app/api/auth/verify-email/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "auth/verify-email");
}
`);
// auth/forgot-password
mkdirSync("app/api/auth/forgot-password", { recursive: true });
writeFileSync("app/api/auth/forgot-password/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/forgot-password");
}
`);
// auth/reset-password
mkdirSync("app/api/auth/reset-password", { recursive: true });
writeFileSync("app/api/auth/reset-password/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "auth/reset-password");
}
`);
// 2fa/generate
mkdirSync("app/api/2fa/generate", { recursive: true });
writeFileSync("app/api/2fa/generate/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/generate");
}
`);
// 2fa/enable
mkdirSync("app/api/2fa/enable", { recursive: true });
writeFileSync("app/api/2fa/enable/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "2fa/enable");
}
`);
// 2fa/disable
mkdirSync("app/api/2fa/disable", { recursive: true });
writeFileSync("app/api/2fa/disable/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function DELETE(req: NextRequest) {
return proxyRequest(req, "2fa/disable");
}
`);
// stripe/subscription
mkdirSync("app/api/stripe/subscription", { recursive: true });
writeFileSync("app/api/stripe/subscription/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function GET(req: NextRequest) {
return proxyRequest(req, "stripe/subscription");
}
`);
// stripe/checkout
mkdirSync("app/api/stripe/checkout", { recursive: true });
writeFileSync("app/api/stripe/checkout/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/checkout");
}
`);
// stripe/portal
mkdirSync("app/api/stripe/portal", { recursive: true });
writeFileSync("app/api/stripe/portal/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "stripe/portal");
}
`);
// transactions/import (multipart CSV upload)
mkdirSync("app/api/transactions/import", { recursive: true });
writeFileSync("app/api/transactions/import/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "transactions/import");
}
`);
// exports/sheets
mkdirSync("app/api/exports/sheets", { recursive: true });
writeFileSync("app/api/exports/sheets/route.ts", `import { NextRequest } from "next/server";
import { proxyRequest } from "../../../../../lib/backend";
export async function POST(req: NextRequest) {
return proxyRequest(req, "exports/sheets");
}
`);
console.log("✅ All API proxy routes written (existing updated + new created)");

View File

@ -0,0 +1,249 @@
import { writeFileSync } from "fs";
writeFileSync("components/app-shell.tsx", `"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { CurrencyToggle, MoodToggle } from "./currency-toggle";
import { apiFetch, clearAuth, getStoredUser } from "../lib/api";
const navItems = [
{ href: "/app", label: "Dashboard" },
{ href: "/app/connect", label: "Accounts" },
{ href: "/transactions", label: "Transactions" },
{ href: "/rules", label: "Rules" },
{ href: "/exports", label: "Exports" },
{ href: "/tax", label: "Tax" },
{ href: "/settings", label: "Settings" },
];
type User = {
id: string;
email: string;
fullName?: string | null;
emailVerified?: boolean;
twoFactorEnabled?: boolean;
};
type SubscriptionData = {
plan?: string;
status?: string;
};
type AppShellProps = {
title: string;
subtitle?: string;
children: React.ReactNode;
};
function initials(user: User): string {
if (user.fullName) {
const parts = user.fullName.trim().split(/\\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return parts[0].slice(0, 2).toUpperCase();
}
return user.email.slice(0, 2).toUpperCase();
}
function displayName(user: User): string {
return user.fullName?.trim() || user.email;
}
export function AppShell({ title, subtitle, children }: AppShellProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [user, setUser] = useState<User | null>(getStoredUser<User>());
const [planLabel, setPlanLabel] = useState("Free Plan");
useEffect(() => {
// Fetch latest user profile
apiFetch<{ user: User }>("/api/auth/me")
.then((res) => {
if (!res.error && res.data?.user) {
setUser(res.data.user);
}
})
.catch(() => {});
// Fetch subscription plan
apiFetch<SubscriptionData>("/api/stripe/subscription")
.then((res) => {
if (!res.error && res.data?.plan) {
const p = res.data.plan;
setPlanLabel(p.charAt(0).toUpperCase() + p.slice(1) + " Plan");
}
})
.catch(() => {});
}, []);
const onLogout = async () => {
const refreshToken = localStorage.getItem("ledgerone_refresh_token") ?? "";
try {
await fetch("/api/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
} catch {}
clearAuth();
router.push("/login");
};
const NavLinks = () => (
<>
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/app" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={\`flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors \${
isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
}\`}
>
<span>{item.label}</span>
</Link>
);
})}
</>
);
return (
<div className="min-h-screen bg-background flex font-sans text-foreground">
{/* Desktop Sidebar */}
<aside className="w-64 bg-background border-r border-border flex-col hidden lg:flex">
<div className="h-16 flex items-center px-6 border-b border-border">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
L1
</div>
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
</div>
</div>
<div className="p-4 space-y-1">
<div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div>
<NavLinks />
</div>
<div className="mt-auto p-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
{user ? initials(user) : "?"}
</div>
<div className="overflow-hidden">
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
</div>
</div>
</div>
</aside>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile Sidebar drawer */}
<aside
className={\`fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border flex flex-col transform transition-transform duration-200 lg:hidden \${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}\`}
>
<div className="h-16 flex items-center justify-between px-6 border-b border-border">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center text-primary-foreground font-bold text-xs shadow-glow-teal">
L1
</div>
<span className="font-semibold text-foreground tracking-tight">LedgerOne</span>
</div>
<button onClick={() => setMobileOpen(false)} className="text-muted-foreground hover:text-foreground">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4 space-y-1 flex-1">
<div className="px-2 py-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Workspace</p>
</div>
<NavLinks />
</div>
<div className="p-4 border-t border-border">
<div className="flex items-center gap-3 px-2 py-2">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium flex-shrink-0">
{user ? initials(user) : "?"}
</div>
<div className="overflow-hidden">
<p className="text-sm font-medium text-foreground truncate">{user ? displayName(user) : "Loading..."}</p>
<p className="text-xs text-muted-foreground truncate">{planLabel}</p>
</div>
</div>
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0">
<header className="h-16 bg-background border-b border-border flex items-center justify-between px-4 lg:px-8">
<div className="flex items-center gap-3">
{/* Hamburger — mobile only */}
<button
className="lg:hidden text-muted-foreground hover:text-foreground"
onClick={() => setMobileOpen(true)}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div>
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
</div>
</div>
<div className="flex items-center gap-3 lg:gap-4">
<div className="relative hidden md:block">
<input
type="text"
placeholder="Search..."
className="w-48 lg:w-64 pl-9 pr-4 py-1.5 bg-secondary/30 border border-border rounded-md text-sm text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
/>
<svg className="w-4 h-4 text-muted-foreground absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<CurrencyToggle />
<MoodToggle />
<button
onClick={onLogout}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Log out
</button>
</div>
</header>
<main className="flex-1 p-4 lg:p-8 overflow-y-auto">
<div className="max-w-6xl mx-auto">
{children}
</div>
</main>
</div>
</div>
);
}
`);
console.log("✅ AppShell updated with real user data and mobile nav");

View File

@ -0,0 +1,594 @@
import { writeFileSync, mkdirSync } from "fs";
// ─── Update settings/page.tsx to include 2FA link ────────────────────────────
writeFileSync("app/settings/page.tsx", `import Link from "next/link";
import { AppShell } from "../../components/app-shell";
const settingsItems = [
{
title: "Profile",
description: "Update company details, contact info, and onboarding fields.",
href: "/settings/profile",
},
{
title: "Two-Factor Auth",
description: "Add a TOTP authenticator app for extra security.",
href: "/settings/2fa",
},
{
title: "Subscription",
description: "View plan details, upgrade options, and billing cadence.",
href: "/settings/subscription",
},
];
export default function SettingsPage() {
return (
<AppShell title="Settings" subtitle="Account preferences and plan configuration.">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{settingsItems.map((item) => (
<Link
key={item.title}
href={item.href}
className="glass-panel p-6 rounded-2xl shadow-sm transition-all hover:-translate-y-1 hover:border-primary/50 group"
>
<p className="text-lg font-bold text-foreground group-hover:text-primary transition-colors">{item.title}</p>
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
<span className="mt-4 inline-flex rounded-full border border-border bg-secondary/50 px-3 py-1 text-xs font-medium text-muted-foreground">
Open
</span>
</Link>
))}
</div>
</AppShell>
);
}
`);
// ─── settings/2fa/page.tsx ───────────────────────────────────────────────────
mkdirSync("app/settings/2fa", { recursive: true });
writeFileSync("app/settings/2fa/page.tsx", `"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "../../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type TwoFaGenerateData = { qrCode: string; otpAuthUrl: string };
type UserData = { user: { twoFactorEnabled: boolean } };
export default function TwoFAPage() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [qrCode, setQrCode] = useState<string>("");
const [otpAuthUrl, setOtpAuthUrl] = useState<string>("");
const [token, setToken] = useState("");
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const [step, setStep] = useState<"idle" | "scan" | "done">("idle");
useEffect(() => {
apiFetch<UserData["user"]>("/api/auth/me")
.then((res) => {
if (!res.error && res.data) {
// me returns { user: {...} }
const data = res.data as unknown as UserData;
setEnabled(data.user?.twoFactorEnabled ?? false);
}
})
.catch(() => {});
}, []);
const handleGenerate = async () => {
setStatus("Generating QR code...");
setIsError(false);
const res = await apiFetch<TwoFaGenerateData>("/api/2fa/generate", { method: "POST" });
if (res.error) {
setStatus(res.error.message ?? "Failed to generate 2FA secret.");
setIsError(true);
return;
}
setQrCode(res.data.qrCode);
setOtpAuthUrl(res.data.otpAuthUrl);
setStep("scan");
setStatus("Scan the QR code with your authenticator app, then enter the code below.");
};
const handleEnable = async (event: React.FormEvent) => {
event.preventDefault();
if (!token || token.length !== 6) {
setStatus("Please enter the 6-digit code.");
setIsError(true);
return;
}
setStatus("Verifying...");
setIsError(false);
const res = await apiFetch<{ message: string }>("/api/2fa/enable", {
method: "POST",
body: JSON.stringify({ token }),
});
if (res.error) {
setStatus(res.error.message ?? "Verification failed. Try again.");
setIsError(true);
return;
}
setEnabled(true);
setStep("done");
setStatus("Two-factor authentication is now active.");
};
const handleDisable = async (event: React.FormEvent) => {
event.preventDefault();
if (!token || token.length !== 6) {
setStatus("Please enter the 6-digit code to confirm.");
setIsError(true);
return;
}
setStatus("Disabling 2FA...");
setIsError(false);
const res = await apiFetch<{ message: string }>("/api/2fa/disable", {
method: "DELETE",
body: JSON.stringify({ token }),
});
if (res.error) {
setStatus(res.error.message ?? "Failed. Check your authenticator code.");
setIsError(true);
return;
}
setEnabled(false);
setToken("");
setStep("idle");
setStatus("Two-factor authentication has been disabled.");
};
return (
<AppShell title="Two-Factor Auth" subtitle="Secure your account with a TOTP authenticator.">
<div className="max-w-lg">
<div className="glass-panel rounded-2xl p-8">
{enabled === null ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
) : enabled ? (
<>
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">2FA is Active</p>
<p className="text-xs text-muted-foreground">Your account is protected with TOTP.</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
To disable two-factor authentication, enter the current code from your authenticator app.
</p>
<form onSubmit={handleDisable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Authenticator Code</label>
<input
type="text" inputMode="numeric" maxLength={6} placeholder="000000"
value={token} onChange={(e) => setToken(e.target.value.replace(/\\D/g, ""))}
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
required
/>
</div>
<button
type="submit"
className="w-full rounded-lg border border-red-500/30 bg-red-500/10 py-2.5 px-4 text-sm font-bold text-red-500 hover:bg-red-500/20 transition-all"
>
Disable 2FA
</button>
</form>
</>
) : step === "idle" ? (
<>
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-full bg-secondary flex items-center justify-center">
<svg className="h-5 w-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">2FA Not Enabled</p>
<p className="text-xs text-muted-foreground">Add an extra layer of protection.</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6">
Use any TOTP authenticator app (Google Authenticator, Authy, 1Password) to generate login codes.
</p>
<button
onClick={handleGenerate}
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Enable Two-Factor Auth
</button>
</>
) : step === "scan" ? (
<>
<p className="text-sm font-bold text-foreground mb-2">Scan this QR code</p>
<p className="text-xs text-muted-foreground mb-4">
Open your authenticator app and scan the code below, or enter the key manually.
</p>
{qrCode && (
<div className="flex justify-center mb-4 bg-white p-3 rounded-xl inline-block">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCode} alt="2FA QR Code" className="h-40 w-40" />
</div>
)}
{otpAuthUrl && (
<p className="text-[10px] text-muted-foreground break-all mb-4 font-mono bg-secondary/30 p-2 rounded">{otpAuthUrl}</p>
)}
<form onSubmit={handleEnable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Enter code to confirm</label>
<input
type="text" inputMode="numeric" maxLength={6} placeholder="6-digit code"
value={token} onChange={(e) => setToken(e.target.value.replace(/\\D/g, ""))}
className="block w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm text-center tracking-widest focus:border-primary focus:outline-none focus:ring-primary transition-all"
required autoFocus
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Verify and Enable
</button>
</form>
</>
) : (
<div className="text-center py-4">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-bold text-foreground">2FA Enabled Successfully</p>
<p className="text-xs text-muted-foreground mt-1">Your account now requires a code on each login.</p>
</div>
)}
{status && (
<div className={\`mt-4 rounded-lg p-3 text-sm text-center \${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}\`}>
{status}
</div>
)}
</div>
</div>
</AppShell>
);
}
`);
// ─── settings/subscription/page.tsx ─────────────────────────────────────────
mkdirSync("app/settings/subscription", { recursive: true });
writeFileSync("app/settings/subscription/page.tsx", `"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "../../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type SubscriptionData = {
plan?: string;
status?: string;
billingCycleAnchor?: number;
cancelAtPeriodEnd?: boolean;
};
const PLAN_LABELS: Record<string, string> = {
free: "Free",
pro: "Pro",
elite: "Elite",
};
const PLAN_DESCRIPTIONS: Record<string, string> = {
free: "Up to 2 accounts, basic CSV export, 30-day history.",
pro: "Unlimited accounts, Google Sheets, 24-month history, priority support.",
elite: "Everything in Pro + tax return module, AI rule suggestions, dedicated support.",
};
export default function SubscriptionPage() {
const [sub, setSub] = useState<SubscriptionData | null>(null);
const [loading, setLoading] = useState(true);
const [actionStatus, setActionStatus] = useState("");
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
apiFetch<SubscriptionData>("/api/stripe/subscription")
.then((res) => {
if (!res.error) setSub(res.data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleUpgrade = async (plan: string) => {
setActionLoading(true);
setActionStatus("Redirecting to checkout...");
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/checkout", {
method: "POST",
body: JSON.stringify({
plan,
successUrl: \`\${appUrl}/settings/subscription?upgraded=1\`,
cancelUrl: \`\${appUrl}/settings/subscription\`,
}),
});
setActionLoading(false);
if (res.error || !res.data?.url) {
setActionStatus(res.error?.message ?? "Could not start checkout. Check Stripe configuration.");
return;
}
window.location.href = res.data.url;
};
const handlePortal = async () => {
setActionLoading(true);
setActionStatus("Redirecting to billing portal...");
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const res = await apiFetch<{ url: string }>("/api/stripe/portal", {
method: "POST",
body: JSON.stringify({ returnUrl: \`\${appUrl}/settings/subscription\` }),
});
setActionLoading(false);
if (res.error || !res.data?.url) {
setActionStatus(res.error?.message ?? "Could not open billing portal. Check Stripe configuration.");
return;
}
window.location.href = res.data.url;
};
const currentPlan = sub?.plan ?? "free";
const planLabel = PLAN_LABELS[currentPlan] ?? currentPlan;
const planDesc = PLAN_DESCRIPTIONS[currentPlan] ?? "";
return (
<AppShell title="Subscription" subtitle="Manage your plan and billing details.">
<div className="max-w-2xl space-y-6">
{/* Current plan card */}
<div className="glass-panel rounded-2xl p-8">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">Current Plan</p>
{loading ? (
<div className="mt-4 h-8 w-32 bg-secondary/60 rounded animate-pulse" />
) : (
<>
<div className="mt-3 flex items-center gap-3">
<span className="text-3xl font-bold text-foreground">{planLabel}</span>
{sub?.status && sub.status !== "active" && sub.status !== "free" && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-yellow-500/10 text-yellow-500 capitalize">
{sub.status}
</span>
)}
{(sub?.status === "active" || currentPlan !== "free") && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-primary/10 text-primary">Active</span>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground">{planDesc}</p>
{sub?.cancelAtPeriodEnd && (
<p className="mt-2 text-xs text-yellow-500">Cancels at end of billing period.</p>
)}
</>
)}
{!loading && currentPlan !== "free" && (
<button
onClick={handlePortal}
disabled={actionLoading}
className="mt-6 rounded-lg border border-border bg-secondary/30 py-2 px-4 text-sm font-medium text-foreground hover:bg-secondary/60 transition-all disabled:opacity-50"
>
Manage Billing
</button>
)}
</div>
{/* Upgrade options */}
{currentPlan === "free" && (
<div className="grid gap-4 md:grid-cols-2">
{(["pro", "elite"] as const).map((plan) => (
<div key={plan} className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
<p className="text-lg font-bold text-foreground capitalize">{plan}</p>
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS[plan]}</p>
<button
onClick={() => handleUpgrade(plan)}
disabled={actionLoading}
className="mt-4 w-full rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
>
Upgrade to {PLAN_LABELS[plan]}
</button>
</div>
))}
</div>
)}
{currentPlan === "pro" && (
<div className="glass-panel rounded-2xl p-6 border border-border hover:border-primary/50 transition-all">
<p className="text-lg font-bold text-foreground">Elite</p>
<p className="mt-1 text-sm text-muted-foreground">{PLAN_DESCRIPTIONS.elite}</p>
<button
onClick={() => handleUpgrade("elite")}
disabled={actionLoading}
className="mt-4 rounded-lg bg-primary py-2 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all disabled:opacity-50"
>
Upgrade to Elite
</button>
</div>
)}
{actionStatus && (
<p className="text-sm text-muted-foreground">{actionStatus}</p>
)}
</div>
</AppShell>
);
}
`);
// ─── settings/profile/page.tsx (fix stale class names) ───────────────────────
mkdirSync("app/settings/profile", { recursive: true });
writeFileSync("app/settings/profile/page.tsx", `"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../../components/app-shell";
import { apiFetch } from "../../../lib/api";
type ProfileData = {
user: {
id: string;
email: string;
fullName?: string | null;
phone?: string | null;
companyName?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
};
};
export default function ProfilePage() {
const [status, setStatus] = useState("");
const [isError, setIsError] = useState(false);
const [fullName, setFullName] = useState("");
const [phone, setPhone] = useState("");
const [companyName, setCompanyName] = useState("");
const [addressLine1, setAddressLine1] = useState("");
const [addressLine2, setAddressLine2] = useState("");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [postalCode, setPostalCode] = useState("");
const [country, setCountry] = useState("");
useEffect(() => {
apiFetch<ProfileData>("/api/auth/me")
.then((res) => {
if (!res.error && res.data) {
const u = (res.data as unknown as ProfileData).user;
if (u) {
setFullName(u.fullName ?? "");
setPhone(u.phone ?? "");
setCompanyName(u.companyName ?? "");
setAddressLine1(u.addressLine1 ?? "");
setAddressLine2(u.addressLine2 ?? "");
setCity(u.city ?? "");
setState(u.state ?? "");
setPostalCode(u.postalCode ?? "");
setCountry(u.country ?? "");
}
}
})
.catch(() => {});
}, []);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setStatus("Saving profile...");
setIsError(false);
const res = await apiFetch<ProfileData>("/api/auth/profile", {
method: "PATCH",
body: JSON.stringify({
fullName: fullName || undefined,
phone: phone || undefined,
companyName: companyName || undefined,
addressLine1: addressLine1 || undefined,
addressLine2: addressLine2 || undefined,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
}),
});
if (res.error) {
setStatus(res.error.message ?? "Profile update failed.");
setIsError(true);
return;
}
setStatus("Profile saved successfully.");
};
const inputCls = "w-full rounded-lg border border-border bg-background/50 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-primary transition-all";
const labelCls = "block text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wide";
return (
<AppShell title="Profile" subtitle="Keep your ledger profile current for exports.">
<div className="max-w-2xl">
<div className="glass-panel p-8 rounded-2xl">
<h2 className="text-xl font-bold text-foreground">Personal & Business Details</h2>
<p className="mt-1 text-sm text-muted-foreground">
These details appear on tax exports and CSV reports.
</p>
<form className="mt-6 grid gap-5 md:grid-cols-2" onSubmit={onSubmit}>
<div className="md:col-span-2">
<label className={labelCls}>Full name</label>
<input className={inputCls} type="text" value={fullName} onChange={(e) => setFullName(e.target.value)} required />
</div>
<div>
<label className={labelCls}>Phone</label>
<input className={inputCls} type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div>
<label className={labelCls}>Company</label>
<input className={inputCls} type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Address line 1</label>
<input className={inputCls} type="text" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className={labelCls}>Address line 2</label>
<input className={inputCls} type="text" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
</div>
<div>
<label className={labelCls}>City</label>
<input className={inputCls} type="text" value={city} onChange={(e) => setCity(e.target.value)} />
</div>
<div>
<label className={labelCls}>State</label>
<input className={inputCls} type="text" value={state} onChange={(e) => setState(e.target.value)} />
</div>
<div>
<label className={labelCls}>Postal code</label>
<input className={inputCls} type="text" value={postalCode} onChange={(e) => setPostalCode(e.target.value)} />
</div>
<div>
<label className={labelCls}>Country</label>
<input className={inputCls} type="text" value={country} onChange={(e) => setCountry(e.target.value)} />
</div>
<div className="md:col-span-2">
<button
type="submit"
className="w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-all hover:-translate-y-0.5"
>
Save profile
</button>
</div>
</form>
{status && (
<div className={\`mt-4 rounded-lg p-3 text-sm text-center \${isError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "bg-accent/10 border border-accent/20"}\`}>
{status}
</div>
)}
</div>
</div>
</AppShell>
);
}
`);
console.log("✅ settings/page.tsx, settings/2fa/page.tsx, settings/subscription/page.tsx, settings/profile/page.tsx written");

View File

@ -0,0 +1,517 @@
import { writeFileSync } from "fs";
writeFileSync("app/transactions/page.tsx", `"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { AppShell } from "../../components/app-shell";
import { apiFetch, getStoredToken } from "../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type TransactionRow = {
id: string;
name?: string;
description?: string;
amount: string;
category?: string | null;
note?: string | null;
status?: string;
hidden?: boolean;
date: string;
accountId?: string | null;
};
type Account = {
id: string;
institutionName: string;
accountType: string;
mask?: string | null;
};
type ImportResult = {
imported: number;
skipped: number;
errors?: string[];
};
export default function TransactionsPage() {
const [rows, setRows] = useState<TransactionRow[]>([]);
const [status, setStatus] = useState("Loading transactions...");
const [summary, setSummary] = useState<{
total: string; count: number; income?: string; expense?: string; net?: string;
} | null>(null);
const [datePreset, setDatePreset] = useState("this_month");
const [showFilters, setShowFilters] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]);
const [autoSync, setAutoSync] = useState(true);
const [isSyncing, setIsSyncing] = useState(false);
const [showManual, setShowManual] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importStatus, setImportStatus] = useState("");
const [importLoading, setImportLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [manualForm, setManualForm] = useState({
accountId: "",
date: new Date().toISOString().slice(0, 10),
description: "",
amount: "",
category: "",
note: "",
});
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ category: "", note: "", hidden: false });
const [filters, setFilters] = useState({
startDate: "", endDate: "", minAmount: "", maxAmount: "",
category: "", source: "", search: "", includeHidden: false,
});
const applyPreset = (preset: string) => {
setDatePreset(preset);
if (preset === "custom") return;
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let start = new Date(end);
if (preset === "this_month") {
start = new Date(end.getFullYear(), end.getMonth(), 1);
} else if (preset === "last_month") {
start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
end.setDate(0);
} else if (preset === "last_6_months") {
start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
} else if (preset === "last_year") {
start = new Date(end.getFullYear() - 1, 0, 1);
end.setMonth(11, 31);
}
const fmt = (d: Date) => d.toISOString().slice(0, 10);
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
};
const buildQuery = () => {
const params = new URLSearchParams();
if (filters.startDate) params.set("start_date", filters.startDate);
if (filters.endDate) params.set("end_date", filters.endDate);
if (filters.minAmount) params.set("min_amount", filters.minAmount);
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
if (filters.category) params.set("category", filters.category);
if (filters.source) params.set("source", filters.source);
if (filters.search) params.set("search", filters.search);
if (filters.includeHidden) params.set("include_hidden", "true");
return params.toString() ? \`?\${params.toString()}\` : "";
};
const load = async () => {
const query = buildQuery();
const res = await apiFetch<TransactionRow[]>(\`/api/transactions\${query}\`);
if (res.error) {
setStatus(res.error.message ?? "Unable to load transactions.");
return;
}
setRows(res.data ?? []);
setStatus((res.data ?? []).length ? "" : "No transactions yet.");
};
const loadAccounts = async () => {
const res = await apiFetch<Account[]>("/api/accounts");
if (!res.error) setAccounts(res.data ?? []);
};
const loadSummary = async () => {
const query = buildQuery();
const res = await apiFetch<{ total: string; count: number }>(\`/api/transactions/summary\${query}\`);
if (!res.error) setSummary(res.data);
};
useEffect(() => {
applyPreset("this_month");
load();
loadSummary();
loadAccounts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!autoSync) return;
const id = setInterval(() => { onSync(); }, 5 * 60 * 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoSync, filters.startDate, filters.endDate]);
const onSync = async () => {
if (isSyncing) return;
setIsSyncing(true);
setStatus("Syncing transactions...");
const res = await apiFetch<unknown>("/api/transactions/sync", {
method: "POST",
body: JSON.stringify({ startDate: filters.startDate || undefined, endDate: filters.endDate || undefined }),
});
if (res.error) {
setStatus(res.error.message ?? "Sync failed.");
setIsSyncing(false);
return;
}
setStatus("Sync complete.");
await load();
await loadSummary();
setIsSyncing(false);
};
const onImportCsv = async (file: File) => {
setImportLoading(true);
setImportStatus("Uploading...");
const formData = new FormData();
formData.append("file", file);
const token = getStoredToken();
try {
const res = await fetch("/api/transactions/import", {
method: "POST",
headers: token ? { Authorization: \`Bearer \${token}\` } : {},
body: formData,
});
const payload = (await res.json()) as ApiResponse<ImportResult>;
if (!res.ok || payload.error) {
setImportStatus(payload.error?.message ?? "Import failed.");
setImportLoading(false);
return;
}
const r = payload.data;
setImportStatus(\`Imported \${r.imported} transaction\${r.imported === 1 ? "" : "s"}, skipped \${r.skipped} duplicate\${r.skipped === 1 ? "" : "s"}.\`);
await load();
await loadSummary();
} catch {
setImportStatus("Import failed. Please try again.");
}
setImportLoading(false);
};
const formatAmount = (value: string) => {
const numeric = Number.parseFloat(value.replace(/[^0-9.-]/g, ""));
if (Number.isNaN(numeric)) return { display: value, tone: "text-foreground" };
return {
display: numeric < 0 ? \`-\$\${Math.abs(numeric).toFixed(2)}\` : \`\$\${numeric.toFixed(2)}\`,
tone: numeric < 0 ? "text-foreground" : "text-primary font-bold",
};
};
const onManualCreate = async (event: React.FormEvent) => {
event.preventDefault();
const amount = Number.parseFloat(manualForm.amount);
if (Number.isNaN(amount)) { setStatus("Invalid amount."); return; }
setStatus("Saving manual transaction...");
const res = await apiFetch<unknown>("/api/transactions/manual", {
method: "POST",
body: JSON.stringify({
accountId: manualForm.accountId || undefined,
date: manualForm.date,
description: manualForm.description,
amount,
category: manualForm.category || undefined,
note: manualForm.note || undefined,
}),
});
if (res.error) { setStatus(res.error.message ?? "Unable to save transaction."); return; }
setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "" }));
setShowManual(false);
setStatus("Manual transaction saved.");
await load();
await loadSummary();
};
const startEdit = (row: TransactionRow) => {
setEditingId(row.id);
setEditForm({ category: row.category ?? "", note: row.note ?? "", hidden: Boolean(row.hidden) });
};
const saveEdit = async () => {
if (!editingId) return;
setStatus("Saving edits...");
const res = await apiFetch<unknown>(\`/api/transactions/\${editingId}/derived\`, {
method: "PATCH",
body: JSON.stringify({
userCategory: editForm.category || undefined,
userNotes: editForm.note || undefined,
isHidden: editForm.hidden,
}),
});
if (res.error) { setStatus(res.error.message ?? "Unable to save edits."); return; }
setEditingId(null);
setStatus("Transaction updated.");
await load();
await loadSummary();
};
const inputCls = "mt-2 w-full rounded-md border border-border bg-background/50 px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs font-semibold text-muted-foreground uppercase tracking-wider";
return (
<AppShell title="Transactions" subtitle="View, sync, and categorize your transactions.">
{/* Action bar */}
<div className="flex flex-wrap items-center gap-2 text-sm mb-6">
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-primary" />
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")}
</span>
<button onClick={onSync} className="ml-auto px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors">
{isSyncing ? "Syncing..." : "Sync"}
</button>
<button onClick={() => setAutoSync((prev) => !prev)} className={\`px-3 py-2 rounded-lg border text-sm font-medium transition-colors \${autoSync ? "bg-primary/10 border-primary/30 text-primary" : "bg-background border-border text-foreground hover:bg-secondary"}\`}>
Auto {autoSync ? "On" : "Off"}
</button>
<button onClick={() => setShowManual((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showManual ? "Hide manual" : "Add manual"}
</button>
<button onClick={() => { setShowImport((prev) => !prev); setImportStatus(""); }} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showImport ? "Hide import" : "Import CSV"}
</button>
<Link href={\`/exports\${buildQuery()}\`} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
Export
</Link>
<button onClick={() => setShowFilters((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showFilters ? "Hide filters" : "Filters"}
</button>
</div>
{/* CSV Import panel */}
{showImport && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<p className="text-sm font-bold text-foreground mb-1">Import CSV</p>
<p className="text-xs text-muted-foreground mb-4">
Supports Chase, Bank of America, Wells Fargo, and generic CSV formats. Duplicate transactions are skipped automatically.
</p>
<div
className="border-2 border-dashed border-border rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith(".csv")) onImportCsv(file);
}}
>
<input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImportCsv(f); }}
/>
<svg className="mx-auto h-8 w-8 text-muted-foreground mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
{importLoading ? (
<p className="text-sm text-muted-foreground">Uploading...</p>
) : (
<p className="text-sm text-muted-foreground">Drop a CSV file here or <span className="text-primary font-medium">click to browse</span></p>
)}
</div>
{importStatus && (
<p className="mt-3 text-sm text-muted-foreground">{importStatus}</p>
)}
</div>
)}
{/* Manual transaction form */}
{showManual && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<p className="text-sm font-bold text-foreground mb-4">Add Manual Transaction</p>
<form onSubmit={onManualCreate} className="grid gap-3 md:grid-cols-3">
<div>
<label className={labelCls}>Date</label>
<input type="date" value={manualForm.date} onChange={(e) => setManualForm((p) => ({ ...p, date: e.target.value }))} className={inputCls} required />
</div>
<div>
<label className={labelCls}>Description</label>
<input type="text" value={manualForm.description} onChange={(e) => setManualForm((p) => ({ ...p, description: e.target.value }))} className={inputCls} required />
</div>
<div>
<label className={labelCls}>Amount (negative = expense)</label>
<input type="number" step="0.01" value={manualForm.amount} onChange={(e) => setManualForm((p) => ({ ...p, amount: e.target.value }))} className={inputCls} required placeholder="-42.50" />
</div>
<div>
<label className={labelCls}>Category</label>
<input type="text" value={manualForm.category} onChange={(e) => setManualForm((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Account</label>
<select value={manualForm.accountId} onChange={(e) => setManualForm((p) => ({ ...p, accountId: e.target.value }))} className={inputCls}>
<option value=""> No account </option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.institutionName} {a.mask ? \`••\${a.mask}\` : ""}</option>
))}
</select>
</div>
<div>
<label className={labelCls}>Note</label>
<input type="text" value={manualForm.note} onChange={(e) => setManualForm((p) => ({ ...p, note: e.target.value }))} className={inputCls} />
</div>
<div className="md:col-span-3 flex justify-end gap-2">
<button type="button" onClick={() => setShowManual(false)} className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-secondary">Cancel</button>
<button type="submit" className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Save</button>
</div>
</form>
</div>
)}
{/* Filters */}
{showFilters && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className={labelCls}>Date range</label>
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
<option value="this_month">This month</option>
<option value="last_month">Last month</option>
<option value="last_6_months">Last 6 months</option>
<option value="last_year">Last year</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className={labelCls}>Start date</label>
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>End date</label>
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>Search</label>
<input type="text" value={filters.search} onChange={(e) => setFilters((p) => ({ ...p, search: e.target.value }))} className={inputCls} placeholder="Description..." />
</div>
<div>
<label className={labelCls}>Category</label>
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Min amount</label>
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Max amount</label>
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
</div>
<div className="flex items-end gap-2">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Include hidden
</label>
</div>
<div className="flex items-end">
<button onClick={() => { load(); loadSummary(); }} className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Apply</button>
</div>
</div>
</div>
)}
{/* Summary cards */}
{summary && (
<div className="mb-6 grid gap-3 md:grid-cols-3">
{[
{ label: "Total", value: \`\$\${Math.abs(Number.parseFloat(summary.total ?? "0")).toFixed(2)}\`, sub: \`\${summary.count} transactions\` },
{ label: "Income", value: \`+\$\${Math.abs(Number.parseFloat(summary.income ?? "0")).toFixed(2)}\`, sub: "Credits" },
{ label: "Expenses", value: \`-\$\${Math.abs(Number.parseFloat(summary.expense ?? "0")).toFixed(2)}\`, sub: "Debits" },
].map((c) => (
<div key={c.label} className="glass-panel rounded-xl p-4 shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">{c.label}</p>
<p className="mt-2 text-xl font-bold text-foreground">{c.value}</p>
<p className="text-xs text-muted-foreground">{c.sub}</p>
</div>
))}
</div>
)}
{/* Transaction table */}
<div className="glass-panel rounded-2xl shadow-sm overflow-hidden">
{status && (
<div className="px-6 py-3 bg-secondary/30 border-b border-border text-sm text-muted-foreground">{status}</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-muted-foreground">
<thead className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold bg-secondary/20">
<tr>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Description</th>
<th className="px-4 py-3">Category</th>
<th className="px-4 py-3 text-right">Amount</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) =>
editingId === row.id ? (
<tr key={row.id} className="border-b border-border bg-secondary/10">
<td className="px-4 py-3" colSpan={2}>
<div className="flex gap-2">
<input
type="text"
value={editForm.category}
onChange={(e) => setEditForm((p) => ({ ...p, category: e.target.value }))}
placeholder="Category"
className="w-28 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
<input
type="text"
value={editForm.note}
onChange={(e) => setEditForm((p) => ({ ...p, note: e.target.value }))}
placeholder="Note"
className="flex-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
<label className="flex items-center gap-1 text-xs text-foreground">
<input type="checkbox" checked={editForm.hidden} onChange={(e) => setEditForm((p) => ({ ...p, hidden: e.target.checked }))} />
Hide
</label>
</div>
</td>
<td className="px-4 py-3" colSpan={3}>
<div className="flex gap-2 justify-end">
<button onClick={saveEdit} className="rounded bg-primary px-3 py-1 text-[11px] font-bold text-primary-foreground hover:bg-primary/90">Save</button>
<button onClick={() => setEditingId(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
</div>
</td>
</tr>
) : (
<tr key={row.id} className={\`border-b border-border hover:bg-secondary/20 transition-colors \${row.hidden ? "opacity-50" : ""}\`}>
<td className="px-4 py-3 font-medium whitespace-nowrap">
{new Date(row.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-4 py-3 text-foreground font-medium max-w-[200px] truncate">
{row.description ?? row.name ?? "—"}
</td>
<td className="px-4 py-3">
{row.category ? (
<span className="inline-flex rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">{row.category}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className={\`px-4 py-3 text-right font-bold \${formatAmount(row.amount).tone}\`}>
{formatAmount(row.amount).display}
</td>
<td className="px-4 py-3 text-right">
<button onClick={() => startEdit(row)} className="text-xs text-primary hover:underline">Edit</button>
</td>
</tr>
)
)}
{!rows.length && !status && (
<tr>
<td colSpan={5} className="px-4 py-12 text-center text-sm text-muted-foreground">
No transactions found. Try adjusting your filters or sync your accounts.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</AppShell>
);
}
`);
console.log("✅ transactions/page.tsx written with CSV import UI and apiFetch");

View File

@ -0,0 +1,221 @@
import { writeFileSync } from "fs";
writeFileSync("app/exports/page.tsx", `"use client";
import { useState } from "react";
import { AppShell } from "../../components/app-shell";
import { apiFetch } from "../../lib/api";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
type ExportData = { status: string; csv?: string; rowCount?: number };
type SheetsData = { spreadsheetUrl?: string; spreadsheetId?: string; rowCount?: number };
export default function ExportsPage() {
const [csvStatus, setCsvStatus] = useState("");
const [sheetsStatus, setSheetsStatus] = useState("");
const [sheetsUrl, setSheetsUrl] = useState<string | null>(null);
const [sheetsLoading, setSheetsLoading] = useState(false);
const [datePreset, setDatePreset] = useState("custom");
const [filters, setFilters] = useState({
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
category: "",
source: "",
includeHidden: false,
});
const applyPreset = (preset: string) => {
setDatePreset(preset);
if (preset === "custom") return;
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let start = new Date(end);
if (preset === "this_month") {
start = new Date(end.getFullYear(), end.getMonth(), 1);
} else if (preset === "last_month") {
start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
end.setDate(0);
} else if (preset === "last_6_months") {
start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
} else if (preset === "last_year") {
start = new Date(end.getFullYear() - 1, 0, 1);
end.setMonth(11, 31);
}
const fmt = (d: Date) => d.toISOString().slice(0, 10);
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
};
const buildParams = () => {
const params = new URLSearchParams();
if (filters.startDate) params.set("start_date", filters.startDate);
if (filters.endDate) params.set("end_date", filters.endDate);
if (filters.minAmount) params.set("min_amount", filters.minAmount);
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
if (filters.category) params.set("category", filters.category);
if (filters.source) params.set("source", filters.source);
if (filters.includeHidden) params.set("include_hidden", "true");
return params;
};
const onExportCsv = async () => {
setCsvStatus("Generating export...");
const params = buildParams();
const query = params.toString() ? \`?\${params.toString()}\` : "";
const res = await apiFetch<ExportData>(\`/api/exports/csv\${query}\`);
if (res.error) { setCsvStatus(res.error.message ?? "Export failed."); return; }
if (res.data.csv) {
const blob = new Blob([res.data.csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = \`ledgerone-export-\${new Date().toISOString().slice(0, 10)}.csv\`;
a.click();
URL.revokeObjectURL(url);
setCsvStatus(\`Export ready (\${res.data.rowCount ?? 0} rows) — file downloaded.\`);
} else {
setCsvStatus("Export ready.");
}
};
const onExportSheets = async () => {
setSheetsLoading(true);
setSheetsStatus("Creating Google Sheet...");
setSheetsUrl(null);
const body: Record<string, unknown> = {};
if (filters.startDate) body.startDate = filters.startDate;
if (filters.endDate) body.endDate = filters.endDate;
if (filters.minAmount) body.minAmount = filters.minAmount;
if (filters.maxAmount) body.maxAmount = filters.maxAmount;
if (filters.category) body.category = filters.category;
if (filters.includeHidden) body.includeHidden = true;
const res = await apiFetch<SheetsData>("/api/exports/sheets", {
method: "POST",
body: JSON.stringify(body),
});
setSheetsLoading(false);
if (res.error) {
setSheetsStatus(res.error.message ?? "Google Sheets export failed.");
return;
}
if (res.data.spreadsheetUrl) {
setSheetsUrl(res.data.spreadsheetUrl);
setSheetsStatus(\`Sheet created with \${res.data.rowCount ?? 0} rows.\`);
} else {
setSheetsStatus("Sheet created.");
}
};
const inputCls = "mt-2 w-full rounded-xl border border-border bg-background/50 px-4 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
const labelCls = "text-xs text-muted-foreground font-semibold uppercase tracking-wider";
return (
<AppShell title="Exports" subtitle="Generate CSV datasets or export to Google Sheets.">
<div className="glass-panel p-8 rounded-2xl shadow-sm space-y-6">
{/* Date preset selector */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className={labelCls}>Date range</label>
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
<option value="custom">Custom</option>
<option value="this_month">This month</option>
<option value="last_month">Last month</option>
<option value="last_6_months">Last 6 months</option>
<option value="last_year">Last year</option>
</select>
</div>
<div>
<label className={labelCls}>Start date</label>
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>End date</label>
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>Category contains</label>
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Min amount ($)</label>
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Max amount ($)</label>
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Include hidden transactions
</label>
</div>
</div>
<div className="border-t border-border" />
{/* Export actions */}
<div className="grid gap-4 md:grid-cols-2">
{/* CSV export */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Download CSV</p>
<p className="mt-1 text-xs text-muted-foreground">Raw and derived transaction fields in comma-separated format.</p>
</div>
</div>
<button
onClick={onExportCsv}
className="mt-4 w-full rounded-lg bg-primary py-2.5 px-4 text-sm font-bold text-primary-foreground hover:bg-primary/90 transition-all"
>
Export CSV
</button>
{csvStatus && <p className="mt-2 text-xs text-muted-foreground">{csvStatus}</p>}
</div>
{/* Google Sheets export */}
<div className="rounded-xl border border-border bg-secondary/10 p-6">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-green-500/10 flex items-center justify-center flex-shrink-0">
<svg className="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="text-sm font-bold text-foreground">Export to Google Sheets</p>
<p className="mt-1 text-xs text-muted-foreground">Creates a new Google Sheet with a dated tab. Requires Google OAuth to be configured.</p>
</div>
</div>
<button
onClick={onExportSheets}
disabled={sheetsLoading}
className="mt-4 w-full rounded-lg border border-green-500/30 bg-green-500/10 py-2.5 px-4 text-sm font-bold text-green-500 hover:bg-green-500/20 transition-all disabled:opacity-50"
>
{sheetsLoading ? "Creating sheet..." : "Export to Google Sheets"}
</button>
{sheetsStatus && <p className="mt-2 text-xs text-muted-foreground">{sheetsStatus}</p>}
{sheetsUrl && (
<a href={sheetsUrl} target="_blank" rel="noopener noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs text-green-500 hover:underline">
Open Sheet
</a>
)}
</div>
</div>
</div>
</AppShell>
);
}
`);
console.log("✅ exports/page.tsx written with CSV download + Google Sheets export");