Merge pull request #27 from MealCraft/feature/magic_link_user_login
Feature/magic link user login
This commit is contained in:
commit
512b2fcb88
22 changed files with 3214 additions and 245 deletions
|
|
@ -9,3 +9,5 @@ services:
|
||||||
command: sleep infinity
|
command: sleep infinity
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/workspaces/monorepo
|
- ..:/workspaces/monorepo
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
|
||||||
4
.github/workflows/deploy-postgres-prod.yml
vendored
4
.github/workflows/deploy-postgres-prod.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
1
db/atlas/stripe_invoice/redo_checksum.sh
Normal file
1
db/atlas/stripe_invoice/redo_checksum.sh
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
atlas migrate hash
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
53
stripe_to_invoice/app/api/auth/callback/route.ts
Normal file
53
stripe_to_invoice/app/api/auth/callback/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
49
stripe_to_invoice/app/api/auth/request-link/route.ts
Normal file
49
stripe_to_invoice/app/api/auth/request-link/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
9
stripe_to_invoice/app/api/db-test/route.ts
Normal file
9
stripe_to_invoice/app/api/db-test/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
66
stripe_to_invoice/app/app/page.tsx
Normal file
66
stripe_to_invoice/app/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
stripe_to_invoice/app/auth/callback/page.tsx
Normal file
40
stripe_to_invoice/app/auth/callback/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
stripe_to_invoice/app/login/page.tsx
Normal file
102
stripe_to_invoice/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,249 +1,60 @@
|
||||||
// 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 you’re 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) */}
|
|
||||||
<pre className="mt-4 p-4 bg-gray-50 border rounded text-sm overflow-x-auto">
|
|
||||||
{`Browser
|
|
||||||
|
|
|
||||||
| POST /auth/login (email)
|
|
||||||
v
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* --------------------------------------------------
|
|
||||||
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
|
<a
|
||||||
href="https://nextjs.org/docs/app/building-your-application/routing/route-handlers"
|
href="/login"
|
||||||
className="text-blue-600 underline"
|
className="inline-block mt-4 px-6 py-3 bg-black text-white rounded text-sm"
|
||||||
target="_blank"
|
|
||||||
>
|
>
|
||||||
nextjs.org/docs/app/.../route-handlers
|
Log in →
|
||||||
</a>
|
</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>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
10
stripe_to_invoice/lib/auth/tokens.ts
Normal file
10
stripe_to_invoice/lib/auth/tokens.ts
Normal 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");
|
||||||
|
}
|
||||||
19
stripe_to_invoice/lib/db.ts
Normal file
19
stripe_to_invoice/lib/db.ts
Normal 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);
|
||||||
29
stripe_to_invoice/lib/email/sendMagicLink.ts
Normal file
29
stripe_to_invoice/lib/email/sendMagicLink.ts
Normal 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}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
3
stripe_to_invoice/lib/schema/index.ts
Normal file
3
stripe_to_invoice/lib/schema/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// lib/schema/index.ts
|
||||||
|
export * from "./users";
|
||||||
|
export * from "./loginTokens";
|
||||||
34
stripe_to_invoice/lib/schema/loginTokens.ts
Normal file
34
stripe_to_invoice/lib/schema/loginTokens.ts
Normal 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(),
|
||||||
|
});
|
||||||
19
stripe_to_invoice/lib/schema/users.ts
Normal file
19
stripe_to_invoice/lib/schema/users.ts
Normal 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(),
|
||||||
|
});
|
||||||
14
stripe_to_invoice/middleware.ts
Normal file
14
stripe_to_invoice/middleware.ts
Normal 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*"],
|
||||||
|
};
|
||||||
2542
stripe_to_invoice/package-lock.json
generated
2542
stripe_to_invoice/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue