feat(auth): signupm, forgot passowrd, reset password all added

This commit is contained in:
2025-09-25 15:42:35 -05:00
parent a30fa8c9f4
commit 86dea6083e
34 changed files with 3698 additions and 49 deletions

View File

@@ -0,0 +1,38 @@
import { Router, type Request, type Response } from "express";
import { authClient } from "../../../pkg/auth/auth-client.js";
import z from "zod";
import { auth } from "../../../pkg/auth/auth.js";
const resetSchema = z.object({
email: z.string(),
});
const router = Router();
router.post("/", async (req: Request, res: Response) => {
try {
const validated = resetSchema.parse(req.body);
const data = await auth.api.requestPasswordReset({
body: {
email: validated.email,
redirectTo: `${process.env.BETTER_AUTH_URL}/user/resetpassword`,
},
});
return res.json(data);
} catch (err) {
if (err instanceof z.ZodError) {
const flattened = z.flattenError(err);
return res.status(400).json({
error: "Validation failed",
details: flattened,
});
}
console.log(err);
return res.status(400).json({ error: err });
}
});
export default router;

View File

@@ -2,10 +2,12 @@ import type { Express, Request, Response } from "express";
import me from "./me.js";
import register from "./register.js";
import userRoles from "./userroles.js";
import resetPassword from "./resetPassword.js";
import { requireAuth } from "../../../pkg/middleware/authMiddleware.js";
export const setupAuthRoutes = (app: Express, basePath: string) => {
app.use(basePath + "/api/user/me", requireAuth(), me);
app.use(basePath + "/api/user/resetpassword", resetPassword);
app.use(basePath + "/api/user/register", register);
app.use(basePath + "/api/user/roles", requireAuth(), userRoles);
};

View File

@@ -9,7 +9,7 @@ import {
import type { auth } from "./auth.js";
export const authClient = createAuthClient({
baseURL: "http://localhost:3000",
baseURL: process.env.BETTER_AUTH_URL,
plugins: [
inferAdditionalFields<typeof auth>(),
usernameClient(),

View File

@@ -5,6 +5,7 @@ import { betterAuth } from "better-auth";
import * as rawSchema from "../db/schema/auth-schema.js";
import type { User } from "better-auth/types";
import { eq } from "drizzle-orm";
import { sendEmail } from "../utils/mail/sendMail.js";
export const schema = {
user: rawSchema.user,
@@ -14,6 +15,7 @@ export const schema = {
jwks: rawSchema.jwks,
apiKey: rawSchema.apikey, // 🔑 rename to apiKey
};
const RESET_EXPIRY_SECONDS = 3600; // 1 hour
export const auth = betterAuth({
database: drizzleAdapter(db, {
@@ -24,11 +26,40 @@ export const auth = betterAuth({
"*.alpla.net",
"http://localhost:5173",
"http://localhost:5500",
"http://localhost:4200",
"http://localhost:4000",
],
appName: "lst",
emailAndPassword: {
enabled: true,
minPasswordLength: 8, // optional config
resetPasswordTokenExpirySeconds: RESET_EXPIRY_SECONDS, // time in seconds
sendResetPassword: async ({ user, token }) => {
const frontendUrl = `${process.env.BETTER_AUTH_URL}/lst/app/user/resetpassword?token=${token}`;
const expiryMinutes = Math.floor(RESET_EXPIRY_SECONDS / 60);
const expiryText =
expiryMinutes >= 60
? `${expiryMinutes / 60} hour${
expiryMinutes === 60 ? "" : "s"
}`
: `${expiryMinutes} minutes`;
const emailData = {
email: user.email,
subject: "LST- Forgot password request",
template: "forgotPassword",
context: {
username: user.name,
email: user.email,
url: frontendUrl,
expiry: expiryText,
},
};
await sendEmail(emailData);
},
// onPasswordReset: async ({ user }, request) => {
// // your logic here
// console.log(`Password for user ${user.email} has been reset.`);
// },
},
plugins: [
//jwt({ jwt: { expirationTime: "1h" } }),

View File

@@ -2,6 +2,24 @@ import type { Request, Response, NextFunction } from "express";
import { db } from "../db/db.js";
import { apiHits } from "../db/schema/apiHits.js";
type StripPasswords<T> = T extends Array<infer U>
? StripPasswords<U>[]
: T extends object
? { [K in keyof T as Exclude<K, "password">]: StripPasswords<T[K]> }
: T;
function stripPasswords<T>(input: T): StripPasswords<T> {
if (Array.isArray(input)) {
return input.map(stripPasswords) as StripPasswords<T>;
} else if (input && typeof input === "object") {
const entries = Object.entries(input as object)
.filter(([key]) => key !== "password")
.map(([key, value]) => [key, stripPasswords(value)]);
return Object.fromEntries(entries) as StripPasswords<T>;
}
return input as StripPasswords<T>;
}
export function apiHitMiddleware(
req: Request,
res: Response,
@@ -15,7 +33,7 @@ export function apiHitMiddleware(
await db.insert(apiHits).values({
method: req.method,
path: req.originalUrl,
body: JSON.stringify(req.body ?? {}),
body: JSON.stringify(req.body ? stripPasswords(req.body) : {}),
status: res.statusCode,
ip: req.ip,
duration: Date.now() - start,

View File

@@ -0,0 +1,123 @@
import type { Address } from "nodemailer/lib/mailer/index.js";
import type { Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
import type Mail from "nodemailer/lib/mailer/index.js";
import os from "os";
import nodemailer from "nodemailer";
import path from "path";
import { fileURLToPath } from "url";
import { promisify } from "util";
import hbs from "nodemailer-express-handlebars";
import { createLogger } from "../../logger/logger.js";
interface HandlebarsMailOptions extends Mail.Options {
template: string;
context: Record<string, unknown>;
}
interface EmailData {
email: string;
subject: string;
template: string;
context: Record<string, unknown>;
}
export const sendEmail = async (data: EmailData): Promise<any> => {
const log = createLogger({ module: "pkg", subModule: "sendMail" });
let transporter: Transporter;
let fromEmail: string | Address;
if (
os.hostname().includes("OLP") &&
process.env.EMAIL_USER &&
process.env.EMAIL_PASSWORD
) {
transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD,
},
//debug: true,
});
// update the from email
fromEmail = process.env.EMAIL_USER;
} else {
// convert to the correct plant token.
let host = `${os.hostname().replace("VMS006", "")}-smtp.alpla.net`;
//const testServers = ["vms036", "VMS036"];
if (os.hostname().includes("VMS036")) {
host = "USMCD1-smtp.alpla.net";
}
// if (plantToken[0].value === "usiow2") {
// host = "USIOW1-smtp.alpla.net";
// }
transporter = nodemailer.createTransport({
host: host,
port: 25,
rejectUnauthorized: false,
//secure: false,
// auth: {
// user: "alplaprod",
// pass: "obelix",
// },
debug: true,
} as SMTPTransport.Options);
// update the from email
fromEmail = `noreply@alpla.com`;
}
// creating the handlbar options
const viewPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"./views/"
);
const handlebarOptions = {
viewEngine: {
extname: ".hbs",
//layoutsDir: path.resolve(viewPath, "layouts"), // Path to layouts directory
defaultLayout: "", // Specify the default layout
partialsDir: viewPath,
},
viewPath: viewPath,
extName: ".hbs", // File extension for Handlebars templates
};
transporter.use("compile", hbs(handlebarOptions));
const mailOptions: HandlebarsMailOptions = {
from: fromEmail,
to: data.email,
subject: data.subject,
//text: "You will have a reset token here and only have 30min to click the link before it expires.",
//html: emailTemplate("BlakesTest", "This is an example with css"),
template: data.template, // Name of the Handlebars template (e.g., 'welcome.hbs')
context: data.context,
};
// now verify and send the email
const sendMailPromise = promisify(transporter.sendMail).bind(transporter);
try {
// Send email and await the result
const info = await sendMailPromise(mailOptions);
log.info(null, `Email was sent to: ${data.email}`);
return { success: true, message: "Email sent.", data: info };
} catch (err) {
console.log(err);
log.error(
{ error: err },
`Error sending Email to : ${data.email}`
);
return { success: false, message: "Error sending email.", error: err };
}
};

View File

@@ -0,0 +1,69 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Password Reset</title>
{{> styles}}
<style>
body {
font-family: Arial, sans-serif;
color: #333333;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #f9fafb;
}
.email-container {
max-width: 600px;
margin: 40px auto;
padding: 20px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
h2 {
color: #1a1a1a;
}
.btn {
display: inline-block;
padding: 12px 24px;
margin: 20px 0;
font-size: 16px;
color: #ffffff;
background-color: #4f46e5;
text-decoration: none;
border-radius: 6px;
}
.footer {
font-size: 13px;
color: #666666;
margin-top: 30px;
}
</style>
</head>
<body>
<div class="email-container">
<h2>Password Reset Request</h2>
<p>Hi {{username}},</p>
<p>We received a request to reset the password for your account ({{email}}). If this was you, you can create a new password by clicking the button below:</p>
<p style="text-align:center;">
<a href="{{url}}" class="btn">Reset Your Password</a>
</p>
<p>If the button above doesnt work, copy and paste the following link into your web browser:</p>
<p style="word-break: break-all; color:#4f46e5;">{{url}}</p>
<p><strong>Note:</strong> This link will expire in <b>{{expiry}}</b> for your security.</p>
<p>If you did not request a password reset, you can safely ignore this email — your account will remain secure.</p>
<p>Thanks,<br/>The LST Team</p>
<div class="footer">
<p>You are receiving this email because a password reset was requested for your account.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,6 @@
<style>
table { width: 100%; background-color: #ffffff; border-collapse: collapse;
border-width: 2px; border-color: #14BDEA; border-style: solid; color:
#000000; } th, td { border: 1px solid #ddd; padding: 8px; } th {
background-color: #f4f4f4; }
</style>