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