mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-22 19:55:37 +02:00
setup react email and nodemailer
This commit is contained in:
parent
c9d98a8e8c
commit
57ebc0e525
11 changed files with 2497 additions and 4754 deletions
7036
package-lock.json
generated
7036
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -9,11 +9,14 @@
|
||||||
"db:hydrate": "npx tsx scripts/hydrate.ts",
|
"db:hydrate": "npx tsx scripts/hydrate.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"build": "next build && tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json",
|
"build": "next build && tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json",
|
||||||
"start": "ENVIRONMENT=prod node dist/server/index.js"
|
"start": "ENVIRONMENT=prod node dist/server/index.js",
|
||||||
|
"email": "email dev --dir emailTemplates --port 3002"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucia-auth/adapter-drizzle": "1.1.0",
|
"@lucia-auth/adapter-drizzle": "1.1.0",
|
||||||
"@node-rs/argon2": "1.8.3",
|
"@node-rs/argon2": "1.8.3",
|
||||||
|
"@react-email/components": "0.0.25",
|
||||||
|
"@react-email/tailwind": "0.1.0",
|
||||||
"axios": "1.7.7",
|
"axios": "1.7.7",
|
||||||
"better-sqlite3": "11.3.0",
|
"better-sqlite3": "11.3.0",
|
||||||
"cookie-parser": "1.4.6",
|
"cookie-parser": "1.4.6",
|
||||||
|
@ -25,6 +28,7 @@
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "2.0.0",
|
||||||
"lucia": "3.2.0",
|
"lucia": "3.2.0",
|
||||||
"next": "14.2.13",
|
"next": "14.2.13",
|
||||||
|
"nodemailer": "6.9.15",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
@ -40,12 +44,14 @@
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
"@types/express": "5.0.0",
|
"@types/express": "5.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "6.4.16",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"drizzle-kit": "0.24.2",
|
"drizzle-kit": "0.24.2",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.13",
|
"eslint-config-next": "14.2.13",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
"react-email": "3.0.1",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tsc-alias": "1.8.10",
|
"tsc-alias": "1.8.10",
|
||||||
"tsx": "4.19.1",
|
"tsx": "4.19.1",
|
||||||
|
|
33
server/emails/index.ts
Normal file
33
server/emails/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
export * from "@server/emails/sendEmail";
|
||||||
|
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import environment from "@server/environment";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
function createEmailClient() {
|
||||||
|
if (
|
||||||
|
!environment.EMAIL_SMTP_HOST ||
|
||||||
|
!environment.EMAIL_SMTP_PORT ||
|
||||||
|
!environment.EMAIL_SMTP_USER ||
|
||||||
|
!environment.EMAIL_SMTP_PASS
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
"Email SMTP configuration is missing. Emails will not be sent.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: environment.EMAIL_SMTP_HOST,
|
||||||
|
port: environment.EMAIL_SMTP_PORT,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: environment.EMAIL_SMTP_USER,
|
||||||
|
pass: environment.EMAIL_SMTP_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emailClient = createEmailClient();
|
||||||
|
|
||||||
|
export default emailClient;
|
33
server/emails/sendEmail.ts
Normal file
33
server/emails/sendEmail.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { render } from "@react-email/components";
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
import emailClient from "@server/emails";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export async function sendEmail(
|
||||||
|
template: ReactElement,
|
||||||
|
opts: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!emailClient) {
|
||||||
|
logger.warn("Email client not configured, skipping email send");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailHtml = await render(template);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
from: opts.from,
|
||||||
|
to: opts.to,
|
||||||
|
subject: opts.subject,
|
||||||
|
html: emailHtml,
|
||||||
|
};
|
||||||
|
|
||||||
|
await emailClient.sendMail(options);
|
||||||
|
|
||||||
|
logger.debug(`Sent email to ${opts.to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default sendEmail;
|
63
server/emails/templates/verifyEmailCode.tsx
Normal file
63
server/emails/templates/verifyEmailCode.tsx
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
Tailwind,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
interface VerifyEmailProps {
|
||||||
|
username?: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerifyEmail = ({
|
||||||
|
username,
|
||||||
|
verificationCode,
|
||||||
|
}: VerifyEmailProps) => {
|
||||||
|
const previewText = `Verify your email, ${username}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind>
|
||||||
|
<Body className="font-sans">
|
||||||
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8">
|
||||||
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
|
Verify Your Email
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
|
Hi {username || "there"},
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
You’ve requested to verify your email. Please use
|
||||||
|
the verification code below:
|
||||||
|
</Text>
|
||||||
|
<Section className="text-center my-6">
|
||||||
|
<Text className="inline-block bg-gray-100 text-xl font-bold text-gray-900 py-2 px-4 border border-gray-300 rounded-md">
|
||||||
|
{verificationCode}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
|
If you didn’t request this, you can safely ignore
|
||||||
|
this email.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
|
Best regards,
|
||||||
|
<br />
|
||||||
|
Fossorial
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VerifyEmail;
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import internal from "@server/routers/internal";
|
import internal from "@server/routers/internal";
|
||||||
import { authenticated, unauthenticated } from "@server/routers/external";
|
import { authenticated, unauthenticated } from "@server/routers/external";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
|
import { User } from "@server/db/schema";
|
||||||
|
|
||||||
const dev = environment.ENVIRONMENT !== "prod";
|
const dev = environment.ENVIRONMENT !== "prod";
|
||||||
|
|
||||||
|
@ -67,3 +68,11 @@ app.prepare().then(() => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { verifySession } from "@server/auth";
|
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
@ -7,7 +6,7 @@ import { unauthorized } from "@server/auth";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { verify } from "@node-rs/argon2";
|
import { verify } from "@node-rs/argon2";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { users } from "@server/db/schema";
|
import { User, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
|
|
||||||
|
@ -34,24 +33,9 @@ export async function disable2fa(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { password } = parsedBody.data;
|
const { password } = parsedBody.data;
|
||||||
|
const user = req.user as User;
|
||||||
|
|
||||||
const { session, user } = await verifySession(req);
|
const validPassword = await verify(user.passwordHash, password, {
|
||||||
if (!session) {
|
|
||||||
return next(unauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, user.id));
|
|
||||||
|
|
||||||
if (!existingUser || !existingUser[0]) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "User does not exist"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validPassword = await verify(existingUser[0].passwordHash, password, {
|
|
||||||
memoryCost: 19456,
|
memoryCost: 19456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
outputLen: 32,
|
outputLen: 32,
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { verify } from "@node-rs/argon2";
|
||||||
import lucia, { verifySession } from "@server/auth";
|
import lucia, { verifySession } from "@server/auth";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { users } from "@server/db/schema";
|
import { users } from "@server/db/schema";
|
||||||
|
import { sendEmail } from "@server/emails";
|
||||||
|
import { VerifyEmail } from "@server/emails/templates/verifyEmailCode";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/utils/response";
|
import response from "@server/utils/response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
|
@ -4,10 +4,10 @@ import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { encodeHex } from "oslo/encoding";
|
import { encodeHex } from "oslo/encoding";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { verifySession, unauthorized } from "@server/auth";
|
import { unauthorized } from "@server/auth";
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { users } from "@server/db/schema";
|
import { User, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { verify } from "@node-rs/argon2";
|
import { verify } from "@node-rs/argon2";
|
||||||
import { createTOTPKeyURI } from "oslo/otp";
|
import { createTOTPKeyURI } from "oslo/otp";
|
||||||
|
@ -40,23 +40,9 @@ export async function requestTotpSecret(
|
||||||
|
|
||||||
const { password } = parsedBody.data;
|
const { password } = parsedBody.data;
|
||||||
|
|
||||||
const { session, user } = await verifySession(req);
|
const user = req.user as User;
|
||||||
if (!session) {
|
|
||||||
return next(unauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await db
|
const validPassword = await verify(user.passwordHash, password, {
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, user.id));
|
|
||||||
|
|
||||||
if (!existingUser || !existingUser[0]) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "User does not exist"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const validPassword = await verify(existingUser[0].passwordHash, password, {
|
|
||||||
memoryCost: 19456,
|
memoryCost: 19456,
|
||||||
timeCost: 2,
|
timeCost: 2,
|
||||||
outputLen: 32,
|
outputLen: 32,
|
||||||
|
|
|
@ -5,10 +5,9 @@ import { fromError } from "zod-validation-error";
|
||||||
import { decodeHex } from "oslo/encoding";
|
import { decodeHex } from "oslo/encoding";
|
||||||
import { TOTPController } from "oslo/otp";
|
import { TOTPController } from "oslo/otp";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { verifySession, unauthorized } from "@server/auth";
|
|
||||||
import { response } from "@server/utils";
|
import { response } from "@server/utils";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { users } from "@server/db/schema";
|
import { User, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const verifyTotpBody = z.object({
|
export const verifyTotpBody = z.object({
|
||||||
|
@ -39,10 +38,7 @@ export async function verifyTotp(
|
||||||
|
|
||||||
const { code } = parsedBody.data;
|
const { code } = parsedBody.data;
|
||||||
|
|
||||||
const { session, user } = await verifySession(req);
|
const user = req.user as User;
|
||||||
if (!session) {
|
|
||||||
return next(unauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.twoFactorEnabled) {
|
if (user.twoFactorEnabled) {
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -50,12 +50,9 @@ authenticated.get("/user/:userId", user.getUser);
|
||||||
authenticated.delete("/user/:userId", user.deleteUser);
|
authenticated.delete("/user/:userId", user.deleteUser);
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
const authRouter = Router();
|
unauthenticated.put("/auth/signup", auth.signup);
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.post("/auth/login", auth.login);
|
||||||
|
unauthenticated.post("/auth/logout", auth.logout);
|
||||||
authRouter.put("/signup", auth.signup);
|
authenticated.post("/auth/verify-totp", auth.verifyTotp);
|
||||||
authRouter.post("/login", auth.login);
|
authenticated.post("/auth/request-totp-secret", auth.requestTotpSecret);
|
||||||
authRouter.post("/logout", auth.logout);
|
authenticated.post("/auth/disable-2fa", auth.disable2fa);
|
||||||
authRouter.post("/verify-totp", auth.verifyTotp);
|
|
||||||
authRouter.post("/request-totp-secret", auth.requestTotpSecret);
|
|
||||||
authRouter.post("/disable-2fa", auth.disable2fa);
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue