completed magic link log in
This commit is contained in:
parent
1472ef2fc7
commit
885bba59ec
12 changed files with 345 additions and 0 deletions
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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*"],
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue