completed magic link log in

This commit is contained in:
Jun-te Kim 2025-12-31 00:02:22 +00:00
parent 1472ef2fc7
commit 885bba59ec
12 changed files with 345 additions and 0 deletions

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,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*"],
};