mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-21 19:24: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:studio": "drizzle-kit studio",
|
||||
"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": {
|
||||
"@lucia-auth/adapter-drizzle": "1.1.0",
|
||||
"@node-rs/argon2": "1.8.3",
|
||||
"@react-email/components": "0.0.25",
|
||||
"@react-email/tailwind": "0.1.0",
|
||||
"axios": "1.7.7",
|
||||
"better-sqlite3": "11.3.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
|
@ -25,6 +28,7 @@
|
|||
"http-errors": "2.0.0",
|
||||
"lucia": "3.2.0",
|
||||
"next": "14.2.13",
|
||||
"nodemailer": "6.9.15",
|
||||
"oslo": "1.2.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
|
@ -40,12 +44,14 @@
|
|||
"@types/cors": "2.8.17",
|
||||
"@types/express": "5.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "6.4.16",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"drizzle-kit": "0.24.2",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.13",
|
||||
"postcss": "^8",
|
||||
"react-email": "3.0.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsc-alias": "1.8.10",
|
||||
"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 { authenticated, unauthenticated } from "@server/routers/external";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { User } from "@server/db/schema";
|
||||
|
||||
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 { verifySession } from "@server/auth";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
@ -7,7 +6,7 @@ import { unauthorized } from "@server/auth";
|
|||
import { z } from "zod";
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db/schema";
|
||||
import { User, users } from "@server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { response } from "@server/utils";
|
||||
|
||||
|
@ -34,24 +33,9 @@ export async function disable2fa(
|
|||
}
|
||||
|
||||
const { password } = parsedBody.data;
|
||||
const user = req.user as User;
|
||||
|
||||
const { session, user } = await verifySession(req);
|
||||
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, {
|
||||
const validPassword = await verify(user.passwordHash, password, {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
|
|
|
@ -2,6 +2,8 @@ import { verify } from "@node-rs/argon2";
|
|||
import lucia, { verifySession } from "@server/auth";
|
||||
import db from "@server/db";
|
||||
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 response from "@server/utils/response";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
|
|
@ -4,10 +4,10 @@ import { z } from "zod";
|
|||
import { fromError } from "zod-validation-error";
|
||||
import { encodeHex } from "oslo/encoding";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { verifySession, unauthorized } from "@server/auth";
|
||||
import { unauthorized } from "@server/auth";
|
||||
import { response } from "@server/utils";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db/schema";
|
||||
import { User, users } from "@server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { verify } from "@node-rs/argon2";
|
||||
import { createTOTPKeyURI } from "oslo/otp";
|
||||
|
@ -40,23 +40,9 @@ export async function requestTotpSecret(
|
|||
|
||||
const { password } = parsedBody.data;
|
||||
|
||||
const { session, user } = await verifySession(req);
|
||||
if (!session) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
const user = req.user as User;
|
||||
|
||||
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, {
|
||||
const validPassword = await verify(user.passwordHash, password, {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
|
|
|
@ -5,10 +5,9 @@ import { fromError } from "zod-validation-error";
|
|||
import { decodeHex } from "oslo/encoding";
|
||||
import { TOTPController } from "oslo/otp";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { verifySession, unauthorized } from "@server/auth";
|
||||
import { response } from "@server/utils";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db/schema";
|
||||
import { User, users } from "@server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const verifyTotpBody = z.object({
|
||||
|
@ -39,10 +38,7 @@ export async function verifyTotp(
|
|||
|
||||
const { code } = parsedBody.data;
|
||||
|
||||
const { session, user } = await verifySession(req);
|
||||
if (!session) {
|
||||
return next(unauthorized());
|
||||
}
|
||||
const user = req.user as User;
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
return next(
|
||||
|
|
|
@ -50,12 +50,9 @@ authenticated.get("/user/:userId", user.getUser);
|
|||
authenticated.delete("/user/:userId", user.deleteUser);
|
||||
|
||||
// Auth routes
|
||||
const authRouter = Router();
|
||||
unauthenticated.use("/auth", authRouter);
|
||||
|
||||
authRouter.put("/signup", auth.signup);
|
||||
authRouter.post("/login", auth.login);
|
||||
authRouter.post("/logout", auth.logout);
|
||||
authRouter.post("/verify-totp", auth.verifyTotp);
|
||||
authRouter.post("/request-totp-secret", auth.requestTotpSecret);
|
||||
authRouter.post("/disable-2fa", auth.disable2fa);
|
||||
unauthenticated.put("/auth/signup", auth.signup);
|
||||
unauthenticated.post("/auth/login", auth.login);
|
||||
unauthenticated.post("/auth/logout", auth.logout);
|
||||
authenticated.post("/auth/verify-totp", auth.verifyTotp);
|
||||
authenticated.post("/auth/request-totp-secret", auth.requestTotpSecret);
|
||||
authenticated.post("/auth/disable-2fa", auth.disable2fa);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue