Merge pull request #27 from MealCraft/feature/magic_link_user_login

Feature/magic link user login
This commit is contained in:
Jun-te Kim 2025-12-31 00:03:30 +00:00 committed by GitHub
commit 512b2fcb88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3214 additions and 245 deletions

View file

@ -9,3 +9,5 @@ services:
command: sleep infinity command: sleep infinity
volumes: volumes:
- ..:/workspaces/monorepo - ..:/workspaces/monorepo
extra_hosts:
- "host.docker.internal:host-gateway"

View file

@ -38,8 +38,8 @@ jobs:
- name: Deploy PROD Postgres - name: Deploy PROD Postgres
run: kubectl apply -f db/k8s/postgres/ run: kubectl apply -f db/k8s/postgres/
- name: Deploy PROD backups # - name: Deploy PROD backups
run: kubectl apply -f db/k8s/backups/ # run: kubectl apply -f db/k8s/backups/
migrate: migrate:
runs-on: mealcraft-runners runs-on: mealcraft-runners

View file

@ -0,0 +1,7 @@
-- 0005_harden_login_tokens.sql
ALTER TABLE login_tokens
RENAME COLUMN token TO token_hash;
-- optional but recommended
CREATE INDEX ON login_tokens (token_hash);

View file

@ -1,6 +1,7 @@
h1:dTHZRXvfJ8E0dSqq2PAuMLfFFRSDvt3OzgJKEGeXz2g= h1:RjeUC9UfXpaaJorJ+072tmUmM0yLI4yO71Cuad9tjA4=
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs= 0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw= 0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg= 0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
0004_login_tokens.sql h1:rj1KcWu/0znh2YvtI7JV8Z2nwtL5rZzONbPwX1P+/PI= 0004_login_tokens.sql h1:rj1KcWu/0znh2YvtI7JV8Z2nwtL5rZzONbPwX1P+/PI=
20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc= 20251228182659_add_used_at_to_login_tokens.sql h1:/0puYQvwBFzpfSKjiZj2XR/7Mui39lS/IbFZW1TPQOc=
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=

View file

@ -0,0 +1 @@
atlas migrate hash

View file

@ -1,36 +1,190 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # 🚀 MVP Next Steps Post SES Setup
## Getting Started This document outlines the concrete next steps to build the MVP now that
Amazon SES email delivery is fully configured and verified.
First, run the development server: ---
```bash ## ✅ Phase 0 — Email Infrastructure (COMPLETED)
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. **Status: DONE**
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - SES domain verified (`juntekim.com`)
- DKIM, SPF, DMARC configured
- Custom MAIL FROM domain enabled
- Test email delivered to Gmail inbox
- SES production access requested
- SMTP credentials generated and stored securely
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. No further SES work is required for MVP.
## Learn More ---
To learn more about Next.js, take a look at the following resources: ## 🔐 Phase 1 — Magic Link Authentication (Core MVP)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ### 1⃣ Define Authentication Model
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! **Decisions**
- Email-only authentication (no passwords)
- Magic links are:
- Single-use
- Time-limited (e.g. 15 minutes)
- Hashed before storage
- No persistent email storage
## Deploy on Vercel **Outcome**
- Clear security model before implementation
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ---
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ### 2⃣ Create Magic Link Token Table
**Required fields**
- `id`
- `email`
- `token_hash`
- `expires_at`
- `used_at`
- `created_at`
**Rules**
- Never store raw tokens
- Reject expired tokens
- Reject reused tokens
- Mark token as used immediately after login
**Outcome**
- Database migration + model ready
---
### 3⃣ Build Email Sending Adapter (SES SMTP)
**Requirements**
- Uses Amazon SES SMTP credentials
- Sends from `no-reply@juntekim.com`
- Generates secure magic link URLs
- Plain-text email (HTML later)
**Example responsibility**
- `sendMagicLink(email, url)`
**Outcome**
- Single reusable email-sending utility
---
## 🔑 Phase 2 — NextAuth Integration
### 4⃣ Configure NextAuth (Email Provider)
**Actions**
- Enable NextAuth Email provider
- Configure SES SMTP transport
- Disable default token storage
- Use custom DB token table
**Outcome**
- NextAuth initialized and functional
---
### 5⃣ Implement `/auth/callback` Logic
**Flow**
1. User clicks magic link
2. Token is hashed and validated
3. Token expiry checked
4. Token marked as used
5. Session created
6. Redirect to app
**Outcome**
- End-to-end login flow works
---
### 6⃣ Minimal Authentication UI
**Pages**
- Email input form
- “Check your email” confirmation screen
- Error states:
- Invalid token
- Expired token
- Already-used token
**Outcome**
- Usable authentication UX
---
## 🛡 Phase 3 — MVP Hardening (Still Lightweight)
### 7⃣ Rate Limiting
Add limits for:
- Magic link requests per email
- Magic link requests per IP
Purpose:
- Prevent abuse
- Protect SES reputation
---
### 8⃣ Basic Logging
Log only:
- Email requested
- Email send success/failure
- Login success/failure
Do **not** store email content.
---
### 9⃣ Production Sanity Checks
Before real users:
- Test login on mobile + desktop
- Test Gmail + Outlook
- Test expired link behavior
- Test reused link rejection
---
## 🚦 MVP Definition of Done
The MVP is considered complete when:
- User enters email
- User receives magic link
- User clicks link
- User is authenticated
- Session persists
No additional features are required to ship.
---
## 🧠 Guiding Principles
- Infrastructure first (done)
- Security before UX polish
- Ship working flows early
- Avoid overbuilding before user feedback
---
## 🧩 Post-MVP (Optional, Later)
Do NOT block MVP on:
- HTML email templates
- Branded emails
- Email analytics
- Admin dashboards
- Multi-provider auth
- Password fallback
Ship first, iterate later.

View file

@ -0,0 +1,53 @@
// app/api/auth/callback/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
import { loginTokens } from "@/lib/schema";
import { and, eq, gt, isNull } from "drizzle-orm";
import { hashToken } from "@/lib/auth/tokens";
export async function POST(req: Request) {
const { token } = await req.json();
if (!token) {
return NextResponse.json({ error: "Missing token" }, { status: 400 });
}
const tokenHash = hashToken(token);
const loginToken = await db
.select()
.from(loginTokens)
.where(
and(
eq(loginTokens.tokenHash, tokenHash),
isNull(loginTokens.usedAt),
gt(loginTokens.expiresAt, new Date())
)
)
.limit(1)
.then((rows) => rows[0]);
if (!loginToken) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 401 }
);
}
// ✅ mark token as used
await db
.update(loginTokens)
.set({ usedAt: new Date() })
.where(eq(loginTokens.id, loginToken.id));
// ✅ FIX: cookies() is async in Next 15+
const cookieStore = await cookies();
cookieStore.set("session", loginToken.userId, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,49 @@
// app/api/auth/request-link/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users, loginTokens } from "@/lib/schema";
import { eq } from "drizzle-orm";
import { generateToken, hashToken } from "@/lib/auth/tokens";
import { sendMagicLinkEmail } from "@/lib/email/sendMagicLink";
export async function POST(req: Request) {
const { email } = await req.json();
if (!email) {
return NextResponse.json({ error: "Email required" }, { status: 400 });
}
// 1. find user
let user = await db
.select()
.from(users)
.where(eq(users.email, email))
.then((rows) => rows[0]);
// 2. create user if missing
if (!user) {
const inserted = await db
.insert(users)
.values({ email })
.returning();
user = inserted[0];
}
// 3. generate token
const token = generateToken();
const tokenHash = hashToken(token);
// 4. store login token
await db.insert(loginTokens).values({
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
});
// 5. send email
const link = `${process.env.APP_URL}/auth/callback?token=${token}`;
await sendMagicLinkEmail(email, link);
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,9 @@
// app/api/db-test/route.ts
import { db } from "@/lib/db";
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
export async function GET() {
await db.execute(sql`select 1`);
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,66 @@
// app/app/page.tsx
import { cookies } from "next/headers";
import { db } from "@/lib/db";
import { users } from "@/lib/schema";
import { eq } from "drizzle-orm";
import Link from "next/link";
export default async function AppPage() {
const cookieStore = await cookies();
const userId = cookieStore.get("session")?.value;
if (!userId) {
return (
<main className="p-8">
<p>You are not logged in.</p>
<Link href="/login" className="underline">
Go to login
</Link>
</main>
);
}
const user = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1)
.then((rows) => rows[0]);
return (
<main className="max-w-2xl mx-auto p-8 space-y-10">
<h1 className="text-2xl font-semibold">
Welcome{user?.email ? `, ${user.email}` : ""}
</h1>
{/* Progress */}
<ol className="space-y-4">
<li className="flex items-center gap-3">
<span className="text-green-600"></span>
<span>
Logged in as <strong>{user?.email}</strong>
</span>
</li>
<li className="flex items-center gap-3">
<span className="text-blue-600"></span>
<span className="font-medium">Connect Stripe</span>
</li>
<li className="text-gray-400">
Xero will be connected after Stripe
</li>
</ol>
{/* Primary CTA */}
<div className="pt-6">
<Link
href="/connect/stripe"
className="inline-block rounded bg-black text-white px-5 py-3"
>
Connect Stripe
</Link>
</div>
</main>
);
}

View file

@ -0,0 +1,40 @@
"use client";
import { useEffect, useRef } from "react";
import { useSearchParams, useRouter } from "next/navigation";
export default function AuthCallbackPage() {
const params = useSearchParams();
const router = useRouter();
const ran = useRef(false);
useEffect(() => {
if (ran.current) return;
ran.current = true;
const token = params.get("token");
if (!token) {
router.replace("/login");
return;
}
fetch("/api/auth/callback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
})
.then(async (res) => {
if (!res.ok) throw new Error(await res.text());
router.replace("/app");
})
.catch(() => {
router.replace("/login");
});
}, [params, router]);
return (
<main className="min-h-screen flex items-center justify-center">
<p className="text-sm text-gray-500">Signing you in</p>
</main>
);
}

View file

@ -0,0 +1,102 @@
// app/login/page.tsx
"use client";
import { useState } from "react";
type Step = "email" | "sent";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [step, setStep] = useState<Step>("email");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/auth/request-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error("Failed to send login link");
}
setStep("sent");
} catch (e) {
setError("Something went wrong. Try again.");
} finally {
setLoading(false);
}
}
return (
<main className="max-w-md mx-auto p-8 space-y-8">
<Progress step={step} />
{step === "email" && (
<>
<h1 className="text-xl font-semibold">Log in</h1>
<input
type="email"
placeholder="you@company.com"
className="w-full border rounded p-2"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
<button
onClick={submit}
disabled={loading || !email}
className="w-full bg-black text-white py-2 rounded"
>
{loading ? "Sending…" : "Send login link"}
</button>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</>
)}
{step === "sent" && (
<>
<h1 className="text-xl font-semibold">Check your email</h1>
<p className="text-gray-600">
We sent a login link to <strong>{email}</strong>.
</p>
<p className="text-gray-500 text-sm">
The link expires in 15 minutes.
</p>
</>
)}
</main>
);
}
function Progress({ step }: { step: Step }) {
const percent = step === "email" ? 50 : 100;
return (
<div>
<div className="h-2 bg-gray-200 rounded">
<div
className="h-2 bg-black rounded transition-all"
style={{ width: `${percent}%` }}
/>
</div>
<p className="text-sm text-gray-600 mt-2">
{step === "email" ? "Enter email" : "Check inbox"}
</p>
</div>
);
}

View file

@ -1,251 +1,62 @@
// app/page.tsx // app/page.tsx
// This page doubles as:
// 1. A landing page
// 2. A product spec
// 3. A reminder to future-me what the hell I was building
// //
// If youre reading this months later: hi 👋 // CORE MVP PAGE
// The product is the automation, not the UI. // Purpose:
// 1. Explain the automation
// 2. Point the user to the next action
//
// Everything else lives elsewhere.
export default function Home() { export default function Home() {
return ( return (
<main className="max-w-3xl mx-auto p-8 space-y-16"> <main className="max-w-2xl mx-auto p-8 space-y-10">
{/* -------------------------------------------------- {/* --------------------------------------------------
Intro What this is
-------------------------------------------------- */} -------------------------------------------------- */}
<section> <section>
<h1 className="text-2xl font-semibold"> <h1 className="text-2xl font-semibold">
Stripe Xero automation Stripe Xero automation
</h1> </h1>
<p className="mt-2 text-gray-600"> <p className="mt-3 text-gray-700">
Automatically create and mark Xero invoices as paid when a Stripe payment succeeds. When a Stripe payment succeeds, a Xero invoice is
<br /> automatically created and marked as paid.
Built for people who value time more than pressing buttons.
</p> </p>
</section> </section>
{/* -------------------------------------------------- {/* --------------------------------------------------
High-level flow (human readable) What the user does
-------------------------------------------------- */} -------------------------------------------------- */}
<section> <section>
<h2 className="text-xl font-medium">How it works (high level)</h2> <h2 className="text-lg font-medium">
How it works
</h2>
<ol className="mt-4 space-y-3 list-decimal list-inside text-gray-700"> <ol className="mt-3 space-y-2 list-decimal list-inside text-gray-700">
<li>Log in via magic link (passwordless)</li> <li>Log in with your email</li>
<li>Connect your Stripe account</li> <li>Connect Stripe</li>
<li>Connect your Xero organisation</li> <li>Connect Xero</li>
<li>A Stripe payment succeeds</li> <li>Invoices handle themselves. You focus on the business.</li>
<li>An invoice appears in Xero as paid</li>
</ol> </ol>
</section> </section>
{/* -------------------------------------------------- {/* --------------------------------------------------
Magic link auth detailed flow Next action
-------------------------------------------------- */} -------------------------------------------------- */}
<section> <section className="pt-4 border-t">
<h2 className="text-xl font-medium">Login flow (magic link)</h2> <p className="text-gray-700">
Start by logging in.
<p className="mt-2 text-gray-600">
Authentication is passwordless. We only store intent and proof of login.
</p> </p>
{/* Text-based flow diagram (easy to read + copy) */} <a
<pre className="mt-4 p-4 bg-gray-50 border rounded text-sm overflow-x-auto"> href="/login"
{`Browser className="inline-block mt-4 px-6 py-3 bg-black text-white rounded text-sm"
| >
| POST /auth/login (email) Log in
v </a>
Backend
- find or create user
- generate token
- hash token
- store login_tokens row
- send email (SES)
|
v
Email (magic link)
|
| GET /auth/callback?token=XYZ
v
Backend
- hash token
- validate token (unused + not expired)
- mark token as used
- create session
|
v
Set session cookie
`}
</pre>
{/* Step-by-step breakdown */}
<ol className="mt-6 space-y-4 list-decimal list-inside text-gray-700">
<li>
User enters their email address.
</li>
<li>
Backend creates (or finds) a user record and stores a one-time login token
in <code className="px-1 bg-gray-100 rounded">login_tokens</code>.
</li>
<li>
An email is sent containing a short-lived magic link.
</li>
<li>
When the link is clicked, the token is validated, marked as used,
and a session is created.
</li>
<li>
A secure session cookie is set. No passwords. No OAuth popups.
</li>
</ol>
</section> </section>
{/* --------------------------------------------------
Stripe Xero automation flow
-------------------------------------------------- */}
<section>
<h2 className="text-xl font-medium">Stripe Xero automation flow</h2>
<pre className="mt-4 p-4 bg-gray-50 border rounded text-sm overflow-x-auto">
{`Stripe payment succeeds
|
| Webhook
v
Backend
- verify Stripe event
- map payment to customer
- create Xero invoice
- mark invoice as paid
|
v
Xero (reconciled automatically)
`}
</pre>
<p className="mt-4 text-gray-600">
Once connected, everything runs automatically.
No manual reconciliation. No awaiting payment state.
</p>
</section>
{/* --------------------------------------------------
Proof
-------------------------------------------------- */}
<section>
<h2 className="text-xl font-medium">Proof, not promises</h2>
<p className="mt-2 text-gray-600">
Your next Stripe payment will automatically reconcile in Xero.
<br />
No manual matching. No bookkeeping busywork.
</p>
</section>
{/* --------------------------------------------------
Pricing
-------------------------------------------------- */}
<section>
<h2 className="text-xl font-medium">Pricing</h2>
<p className="mt-2 text-gray-700">
£200 / month unlimited invoices.
</p>
</section>
{/* --------------------------------------------------
Footer / reminder
-------------------------------------------------- */}
<section className="pt-8 border-t">
<p className="text-gray-500 text-sm">
This page is intentionally simple.
<br />
The product is the automation, not the UI.
</p>
</section>
<section>
<h2 className="text-xl font-medium">Implementation notes (for future me)</h2>
<p className="mt-2 text-gray-600">
These are the only docs needed to implement magic-link auth with Next.js + AWS SES.
</p>
<ul className="mt-4 space-y-2 list-disc list-inside text-gray-700">
<li>
Next.js Route Handlers (auth endpoints):{" "}
<a
href="https://nextjs.org/docs/app/building-your-application/routing/route-handlers"
className="text-blue-600 underline"
target="_blank"
>
nextjs.org/docs/app/.../route-handlers
</a>
</li>
<li>
Next.js Server Actions (optional):{" "}
<a
href="https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions"
className="text-blue-600 underline"
target="_blank"
>
nextjs.org/docs/app/.../server-actions
</a>
</li>
<li>
AWS SES sending email (Node.js):{" "}
<a
href="https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/sesv2/"
className="text-blue-600 underline"
target="_blank"
>
AWS SDK SESv2
</a>
</li>
<li>
AWS SES sandbox production access:{" "}
<a
href="https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html"
className="text-blue-600 underline"
target="_blank"
>
Request production access
</a>
</li>
<li>
Node.js crypto (token generation + hashing):{" "}
<a
href="https://nodejs.org/api/crypto.html"
className="text-blue-600 underline"
target="_blank"
>
nodejs.org/api/crypto
</a>
</li>
<li>
Cookies & sessions:{" "}
<a
href="https://nextjs.org/docs/app/api-reference/functions/cookies"
className="text-blue-600 underline"
target="_blank"
>
Next.js cookies API
</a>
</li>
</ul>
</section>
</main> </main>
) )
} }

View file

@ -0,0 +1,10 @@
// lib/auth/tokens.ts
import crypto from "crypto";
export function generateToken() {
return crypto.randomBytes(32).toString("hex");
}
export function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}

View file

@ -0,0 +1,19 @@
// lib/db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
// Fail fast if env is missing
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl:
process.env.NODE_ENV === "production"
? { rejectUnauthorized: false }
: false,
});
// Export a single db instance
export const db = drizzle(pool);

View file

@ -0,0 +1,29 @@
// lib/email/sendMagicLink.ts
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
const ses = new SESClient({
region: process.env.AWS_REGION!,
});
export async function sendMagicLinkEmail(
to: string,
link: string
) {
await ses.send(
new SendEmailCommand({
Source: process.env.SES_FROM_EMAIL!,
Destination: { ToAddresses: [to] },
Message: {
Subject: { Data: "Your login link" },
Body: {
Text: {
Data: `Click the link below to log in.
This link expires in 15 minutes.
${link}`,
},
},
},
})
);
}

View file

@ -0,0 +1,3 @@
// lib/schema/index.ts
export * from "./users";
export * from "./loginTokens";

View file

@ -0,0 +1,34 @@
// lib/schema/loginTokens.ts
import {
pgTable,
uuid,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { users } from "./users";
export const loginTokens = pgTable("login_tokens", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, {
onDelete: "cascade",
}),
tokenHash: text("token_hash").notNull(),
expiresAt: timestamp("expires_at", {
withTimezone: true,
}).notNull(),
usedAt: timestamp("used_at", {
withTimezone: true,
}),
createdAt: timestamp("created_at", {
withTimezone: true,
})
.notNull()
.defaultNow(),
});

View file

@ -0,0 +1,19 @@
// lib/schema/users.ts
import {
pgTable,
uuid,
text,
timestamp,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at", {
withTimezone: true,
})
.notNull()
.defaultNow(),
});

View file

@ -0,0 +1,14 @@
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
export function middleware(req: NextRequest) {
const session = req.cookies.get("session");
if (!session && req.nextUrl.pathname.startsWith("/app")) {
return NextResponse.redirect(new URL("/login", req.url));
}
}
export const config = {
matcher: ["/app/:path*"],
};

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,10 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-ses": "^3.958.0",
"drizzle-orm": "^0.45.1",
"next": "16.0.10", "next": "16.0.10",
"pg": "^8.16.3",
"react": "19.2.1", "react": "19.2.1",
"react-dom": "19.2.1" "react-dom": "19.2.1"
}, },
@ -18,6 +21,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.10", "eslint-config-next": "16.0.10",
"tailwindcss": "^4", "tailwindcss": "^4",