notshop-bundle/server/auth.ts

129 lines
3.7 KiB
TypeScript

/**
* Magic-link customer authentication.
*
* Flow:
* 1. Customer submits email → POST /api/auth/magic-link
* 2. Server stores a token (15-min TTL) on the customer record
* 3. Email with link is sent: /account/verify?token=<token>
* 4. GET /api/auth/verify?token=<token> validates token, sets session
*
* Session is stored in express-session (PostgreSQL-backed via connect-pg-simple).
*/
import { db } from "./db";
import { customers } from "../shared/schema";
import { eq } from "drizzle-orm";
import crypto from "crypto";
import { sendMagicLink } from "./email";
import type { Request, Response } from "express";
function generateToken(): string {
return crypto.randomBytes(32).toString("hex");
}
export async function requestMagicLink(req: Request, res: Response) {
const { email } = req.body as { email?: string };
if (!email || !email.includes("@")) {
return res.status(400).json({ error: "Valid email required" });
}
const normalEmail = email.toLowerCase().trim();
const token = generateToken();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
let [customer] = await db
.select({ id: customers.id })
.from(customers)
.where(eq(customers.email, normalEmail));
if (!customer) {
const [created] = await db
.insert(customers)
.values({ email: normalEmail, magicLinkToken: token, magicLinkTokenExpiresAt: expiresAt })
.returning({ id: customers.id });
customer = created;
} else {
await db
.update(customers)
.set({ magicLinkToken: token, magicLinkTokenExpiresAt: expiresAt })
.where(eq(customers.id, customer.id));
}
const baseUrl = `${req.protocol}://${req.get("host")}`;
await sendMagicLink(normalEmail, token, baseUrl);
res.json({ ok: true });
}
export async function verifyMagicLink(req: Request, res: Response) {
const { token } = req.query as { token?: string };
if (!token) return res.status(400).json({ error: "Token required" });
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.magicLinkToken, token));
if (!customer || !customer.magicLinkTokenExpiresAt) {
return res.status(401).json({ error: "Invalid or expired token" });
}
if (new Date() > new Date(customer.magicLinkTokenExpiresAt)) {
return res.status(401).json({ error: "Token expired" });
}
await db
.update(customers)
.set({ magicLinkToken: null, magicLinkTokenExpiresAt: null })
.where(eq(customers.id, customer.id));
(req.session as any).customerId = customer.id;
res.json({
ok: true,
customer: {
id: customer.id,
email: customer.email,
firstName: customer.firstName,
lastName: customer.lastName,
},
});
}
export function requireCustomer(req: Request, res: Response, next: Function) {
if (!(req.session as any).customerId) {
return res.status(401).json({ error: "Authentication required" });
}
next();
}
export function requireAdmin(req: Request, res: Response, next: Function) {
if (!(req.session as any).adminUserId) {
return res.status(401).json({ error: "Admin access required" });
}
next();
}
export async function logout(req: Request, res: Response) {
req.session.destroy(() => {
res.json({ ok: true });
});
}
export async function me(req: Request, res: Response) {
const customerId = (req.session as any).customerId;
if (!customerId) return res.json({ customer: null });
const [customer] = await db
.select({
id: customers.id,
email: customers.email,
firstName: customers.firstName,
lastName: customers.lastName,
phone: customers.phone,
})
.from(customers)
.where(eq(customers.id, customerId));
res.json({ customer: customer || null });
}