Enrollment + almost all auth

This commit is contained in:
mdares
2026-01-03 20:18:39 +00:00
parent 0ad2451dd4
commit a0ed517047
40 changed files with 3559 additions and 31 deletions

14
lib/appUrl.ts Normal file
View File

@@ -0,0 +1,14 @@
export function getBaseUrl(req?: Request) {
const envUrl = process.env.APP_BASE_URL || process.env.NEXT_PUBLIC_APP_URL;
if (envUrl) return String(envUrl).replace(/\/+$/, "");
if (!req) return "http://localhost:3000";
const forwardedProto = req.headers.get("x-forwarded-proto");
const proto = forwardedProto ? forwardedProto.split(",")[0].trim() : new URL(req.url).protocol.replace(":", "");
const host =
req.headers.get("x-forwarded-host") ||
req.headers.get("host") ||
new URL(req.url).host;
return `${proto}://${host}`;
}

View File

@@ -6,7 +6,7 @@ const COOKIE_NAME = "mis_session";
export async function requireSession() {
const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value;
if (!sessionId) throw new Error("UNAUTHORIZED");
if (!sessionId) return null;
const session = await prisma.session.findFirst({
where: {
@@ -14,9 +14,21 @@ export async function requireSession() {
revokedAt: null,
expiresAt: { gt: new Date() },
},
include: {
user: {
select: { isActive: true, emailVerifiedAt: true },
},
},
});
if (!session) throw new Error("UNAUTHORIZED");
if (!session) return null;
if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
await prisma.session
.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
.catch(() => {});
return null;
}
// Optional: update lastSeenAt (useful later)
await prisma.session

20
lib/auth/sessionCookie.ts Normal file
View File

@@ -0,0 +1,20 @@
export const COOKIE_NAME = "mis_session";
export const SESSION_DAYS = 7;
export function isSecureRequest(req: Request) {
const forwardedProto = req.headers.get("x-forwarded-proto");
if (forwardedProto) {
return forwardedProto.split(",")[0].trim() === "https";
}
return new URL(req.url).protocol === "https:";
}
export function buildSessionCookieOptions(req: Request) {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: isSecureRequest(req),
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
};
}

86
lib/email.ts Normal file
View File

@@ -0,0 +1,86 @@
import nodemailer from "nodemailer";
type EmailPayload = {
to: string;
subject: string;
text: string;
html: string;
};
let cachedTransport: nodemailer.Transporter | null = null;
function getTransporter() {
if (cachedTransport) return cachedTransport;
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT || 465);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const secure =
process.env.SMTP_SECURE !== undefined
? process.env.SMTP_SECURE === "true"
: port === 465;
if (!host || !user || !pass) {
throw new Error("SMTP not configured");
}
cachedTransport = nodemailer.createTransport({
host,
port,
secure,
auth: { user, pass },
});
return cachedTransport;
}
export async function sendEmail(payload: EmailPayload) {
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
if (!from) {
throw new Error("SMTP_FROM not configured");
}
const transporter = getTransporter();
return transporter.sendMail({
from,
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
}
export function buildVerifyEmail(params: { appName: string; verifyUrl: string }) {
const subject = `Verify your ${params.appName} account`;
const text =
`Welcome to ${params.appName}.\n\n` +
`Verify your email to activate your account:\n${params.verifyUrl}\n\n` +
`If you did not request this, ignore this email.`;
const html =
`<p>Welcome to ${params.appName}.</p>` +
`<p>Verify your email to activate your account:</p>` +
`<p><a href="${params.verifyUrl}">${params.verifyUrl}</a></p>` +
`<p>If you did not request this, ignore this email.</p>`;
return { subject, text, html };
}
export function buildInviteEmail(params: {
appName: string;
orgName: string;
inviteUrl: string;
}) {
const subject = `You're invited to ${params.orgName} on ${params.appName}`;
const text =
`You have been invited to join ${params.orgName} on ${params.appName}.\n\n` +
`Accept the invite here:\n${params.inviteUrl}\n\n` +
`If you did not expect this invite, you can ignore this email.`;
const html =
`<p>You have been invited to join ${params.orgName} on ${params.appName}.</p>` +
`<p>Accept the invite here:</p>` +
`<p><a href="${params.inviteUrl}">${params.inviteUrl}</a></p>` +
`<p>If you did not expect this invite, you can ignore this email.</p>`;
return { subject, text, html };
}

16
lib/pairingCode.ts Normal file
View File

@@ -0,0 +1,16 @@
import { randomBytes } from "crypto";
const PAIRING_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function generatePairingCode(length = 5) {
const bytes = randomBytes(length);
let code = "";
for (let i = 0; i < length; i += 1) {
code += PAIRING_ALPHABET[bytes[i] % PAIRING_ALPHABET.length];
}
return code;
}
export function normalizePairingCode(input: string) {
return input.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
}