From 885bba59ecc59e2081f6232829b3fe0facb3a73f Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 31 Dec 2025 00:02:22 +0000 Subject: [PATCH] completed magic link log in --- .../app/api/auth/callback/route.ts | 53 +++++++++++++++ .../app/api/auth/request-link/route.ts | 49 ++++++++++++++ stripe_to_invoice/app/api/db-test/route.ts | 9 +++ stripe_to_invoice/app/app/page.tsx | 66 +++++++++++++++++++ stripe_to_invoice/app/auth/callback/page.tsx | 40 +++++++++++ stripe_to_invoice/lib/auth/tokens.ts | 10 +++ stripe_to_invoice/lib/db.ts | 19 ++++++ stripe_to_invoice/lib/email/sendMagicLink.ts | 29 ++++++++ stripe_to_invoice/lib/schema/index.ts | 3 + stripe_to_invoice/lib/schema/loginTokens.ts | 34 ++++++++++ stripe_to_invoice/lib/schema/users.ts | 19 ++++++ stripe_to_invoice/middleware.ts | 14 ++++ 12 files changed, 345 insertions(+) create mode 100644 stripe_to_invoice/app/api/auth/callback/route.ts create mode 100644 stripe_to_invoice/app/api/auth/request-link/route.ts create mode 100644 stripe_to_invoice/app/api/db-test/route.ts create mode 100644 stripe_to_invoice/app/app/page.tsx create mode 100644 stripe_to_invoice/app/auth/callback/page.tsx create mode 100644 stripe_to_invoice/lib/auth/tokens.ts create mode 100644 stripe_to_invoice/lib/db.ts create mode 100644 stripe_to_invoice/lib/email/sendMagicLink.ts create mode 100644 stripe_to_invoice/lib/schema/index.ts create mode 100644 stripe_to_invoice/lib/schema/loginTokens.ts create mode 100644 stripe_to_invoice/lib/schema/users.ts create mode 100644 stripe_to_invoice/middleware.ts diff --git a/stripe_to_invoice/app/api/auth/callback/route.ts b/stripe_to_invoice/app/api/auth/callback/route.ts new file mode 100644 index 0000000..edaa847 --- /dev/null +++ b/stripe_to_invoice/app/api/auth/callback/route.ts @@ -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 }); +} diff --git a/stripe_to_invoice/app/api/auth/request-link/route.ts b/stripe_to_invoice/app/api/auth/request-link/route.ts new file mode 100644 index 0000000..4468063 --- /dev/null +++ b/stripe_to_invoice/app/api/auth/request-link/route.ts @@ -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 }); +} diff --git a/stripe_to_invoice/app/api/db-test/route.ts b/stripe_to_invoice/app/api/db-test/route.ts new file mode 100644 index 0000000..03d9297 --- /dev/null +++ b/stripe_to_invoice/app/api/db-test/route.ts @@ -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 }); +} diff --git a/stripe_to_invoice/app/app/page.tsx b/stripe_to_invoice/app/app/page.tsx new file mode 100644 index 0000000..2562c25 --- /dev/null +++ b/stripe_to_invoice/app/app/page.tsx @@ -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 ( +
+

You are not logged in.

+ + Go to login + +
+ ); + } + + const user = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1) + .then((rows) => rows[0]); + + return ( +
+

+ Welcome{user?.email ? `, ${user.email}` : ""} +

+ + {/* Progress */} +
    +
  1. + + + Logged in as {user?.email} + +
  2. + +
  3. + + Connect Stripe +
  4. + +
  5. + Xero will be connected after Stripe +
  6. +
+ + {/* Primary CTA */} +
+ + Connect Stripe + +
+
+ ); +} diff --git a/stripe_to_invoice/app/auth/callback/page.tsx b/stripe_to_invoice/app/auth/callback/page.tsx new file mode 100644 index 0000000..1f76a57 --- /dev/null +++ b/stripe_to_invoice/app/auth/callback/page.tsx @@ -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 ( +
+

Signing you in…

+
+ ); +} diff --git a/stripe_to_invoice/lib/auth/tokens.ts b/stripe_to_invoice/lib/auth/tokens.ts new file mode 100644 index 0000000..4443e05 --- /dev/null +++ b/stripe_to_invoice/lib/auth/tokens.ts @@ -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"); +} diff --git a/stripe_to_invoice/lib/db.ts b/stripe_to_invoice/lib/db.ts new file mode 100644 index 0000000..09a2bf7 --- /dev/null +++ b/stripe_to_invoice/lib/db.ts @@ -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); diff --git a/stripe_to_invoice/lib/email/sendMagicLink.ts b/stripe_to_invoice/lib/email/sendMagicLink.ts new file mode 100644 index 0000000..0401645 --- /dev/null +++ b/stripe_to_invoice/lib/email/sendMagicLink.ts @@ -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}`, + }, + }, + }, + }) + ); +} diff --git a/stripe_to_invoice/lib/schema/index.ts b/stripe_to_invoice/lib/schema/index.ts new file mode 100644 index 0000000..58cd6d0 --- /dev/null +++ b/stripe_to_invoice/lib/schema/index.ts @@ -0,0 +1,3 @@ +// lib/schema/index.ts +export * from "./users"; +export * from "./loginTokens"; diff --git a/stripe_to_invoice/lib/schema/loginTokens.ts b/stripe_to_invoice/lib/schema/loginTokens.ts new file mode 100644 index 0000000..7673d2d --- /dev/null +++ b/stripe_to_invoice/lib/schema/loginTokens.ts @@ -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(), +}); diff --git a/stripe_to_invoice/lib/schema/users.ts b/stripe_to_invoice/lib/schema/users.ts new file mode 100644 index 0000000..0aee763 --- /dev/null +++ b/stripe_to_invoice/lib/schema/users.ts @@ -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(), +}); diff --git a/stripe_to_invoice/middleware.ts b/stripe_to_invoice/middleware.ts new file mode 100644 index 0000000..f44f856 --- /dev/null +++ b/stripe_to_invoice/middleware.ts @@ -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*"], +}; \ No newline at end of file