feat(auth): added in the intital setup auth

This commit is contained in:
2025-12-30 08:04:10 -06:00
parent 9b5a75300a
commit ff2cd7e9f8
18 changed files with 2208 additions and 15 deletions

View File

@@ -1,30 +1,29 @@
import { toNodeHandler } from "better-auth/node";
import express from "express";
import morgan from "morgan";
import { createLogger } from "./src/logger/logger.controller.js";
import { connectProdSql } from "./src/prodSql/prodSqlConnection.controller.js";
import { setupRoutes } from "./src/routeHandler.routes.js";
import { auth } from "./src/utils/auth.utils.js";
import { lstCors } from "./src/utils/cors.utils.js";
const port = Number(process.env.PORT);
const startApp = async () => {
const createApp = async () => {
const log = createLogger({ module: "system", subModule: "main start" });
const app = express();
let baseUrl = "/";
// global env that run only in dev
if (process.env.NODE_ENV?.trim() !== "production") {
app.use(morgan("tiny"));
baseUrl = "/lst";
}
// start the connection to the prod sql server
connectProdSql();
app.set("trust proxy", true);
app.all(`${baseUrl}api/auth/*splat`, toNodeHandler(auth));
app.use(express.json());
app.use(lstCors());
setupRoutes(baseUrl, app);
app.listen(port, () => {
log.info(`Listening on port ${port}`);
});
log.info("Express app created");
return { app, baseUrl };
};
startApp();
export default createApp;

22
backend/server.ts Normal file
View File

@@ -0,0 +1,22 @@
import os from "node:os";
import createApp from "./app.js";
import { createLogger } from "./src/logger/logger.controller.js";
import { connectProdSql } from "./src/prodSql/prodSqlConnection.controller.js";
const port = Number(process.env.PORT);
const start = async () => {
const log = createLogger({ module: "system", subModule: "main start" });
connectProdSql();
const { app, baseUrl } = await createApp();
app.listen(port, async () => {
log.info(
`Listening on http://${os.hostname()}:${port}${baseUrl}, logging in ${process.env.LOG_LEVEL}`,
);
});
};
start();

View File

@@ -0,0 +1,157 @@
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
username: text("username").unique(),
displayUsername: text("display_username"),
lastLogin: timestamp("last_login").defaultNow(),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const jwks = pgTable("jwks", {
id: text("id").primaryKey(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
createdAt: timestamp("created_at").notNull(),
expiresAt: timestamp("expires_at"),
});
export const apikey = pgTable(
"apikey",
{
id: text("id").primaryKey(),
name: text("name"),
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_key_idx").on(table.key),
index("apikey_userId_idx").on(table.userId),
],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
apikeys: many(apikey),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(user, {
fields: [apikey.userId],
references: [user.id],
}),
}));

View File

@@ -18,7 +18,7 @@ export const logs = pgTable("logs", {
stack: jsonb("stack").default([]),
checked: boolean("checked").default(false),
hostname: text("hostname"),
createdAt: timestamp("createdAt").defaultNow(),
createdAt: timestamp("created_at").defaultNow(),
});
export const logSchema = createSelectSchema(logs);

View File

@@ -0,0 +1,87 @@
import { betterAuth, type User } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, apiKey, jwt, username } from "better-auth/plugins";
import { eq } from "drizzle-orm";
import { db } from "../db/db.controller.js";
import * as rawSchema from "../db/schema/auth.schema.js";
import { allowedOrigins } from "./cors.utils.js";
import { sendEmail } from "./sendEmail.utils.js";
export const schema = {
user: rawSchema.user,
session: rawSchema.session,
account: rawSchema.account,
verification: rawSchema.verification,
jwks: rawSchema.jwks,
apiKey: rawSchema.apikey, // 🔑 rename to apiKey
};
export const auth = betterAuth({
appName: "lst",
baseURL: process.env.URL,
database: drizzleAdapter(db, {
provider: "pg",
}),
plugins: [
jwt({ jwt: { expirationTime: "1h" } }),
apiKey(),
admin(),
username(),
],
trustedOrigins: allowedOrigins,
// email or username and password.
emailAndPassword: {
enabled: true,
minPasswordLength: 8, // optional config
resetPasswordTokenExpirySeconds: process.env.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(
parseInt(process.env.RESET_EXPIRY_SECONDS ?? "3600", 10) / 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.`);
// },
},
session: {
expiresIn: 60 * 60,
updateAge: 60 * 5,
freshAge: 60 * 2,
cookieCache: {
enabled: true,
maxAge: 5 * 60,
},
},
cookie: {
path: "/lst/app",
sameSite: "lax",
secure: false,
httpOnly: true,
},
events: {
async onSignInSuccess({ user }: { user: User }) {
await db
.update(rawSchema.user)
.set({ lastLogin: new Date() })
.where(eq(schema.user.id, user.id));
},
},
});

View File

@@ -0,0 +1,53 @@
import cors from "cors";
export const allowedOrigins = [
"*.alpla.net",
"http://localhost:4173",
"http://localhost:4200",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:4000",
"http://localhost:4001",
"http://localhost:5500",
`${process.env.URL}`,
];
export const lstCors = () => {
return cors({
origin: (origin, callback) => {
//console.log("CORS request from origin:", origin);
if (!origin) return callback(null, true); // allow same-site or direct calls
try {
const hostname = new URL(origin).hostname; // strips protocol/port
//console.log("Parsed hostname:", hostname);
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Now this works for *.alpla.net
if (hostname.endsWith(".alpla.net") || hostname === "alpla.net") {
return callback(null, true);
}
} catch (_) {
//console.error("Invalid Origin header:", origin);
}
return callback(new Error(`Not allowed by CORS: ${origin}`));
},
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
credentials: true,
exposedHeaders: ["set-cookie", "expo-protocol-version", "expo-sfv-version"],
allowedHeaders: [
"Content-Type",
"Authorization",
"X-Requested-With",
"XMLHttpRequest",
"expo-runtime-version",
"expo-platform",
"expo-channel-name",
"*",
],
});
};

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>

View File

@@ -0,0 +1,33 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Order Summary</title>
{{> styles}}
{{!-- <link rel="stylesheet" href="styles/styles.css" /> --}}
</head>
<body>
<h1>{{name}}, This is a test that the emailing actually works as intended.</h1>
<p>All,
This is an example of the test email
</p>
{{!-- <table>
<thead>
<tr>
<th>Item Name</th>
<th>Quantity</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{name}}</td>
<td>{{quantity}}</td>
<td>{{price}}</td>
</tr>
{{/each}}
</tbody>
</table> --}}
</body>
</html>

View File

@@ -3,8 +3,8 @@ import { createLogger } from "../logger/logger.controller.js";
interface Data {
success: boolean;
module: "system" | "ocp" | "routes" | "datamart";
subModule: "db" | "labeling" | "printer" | "prodSql" | "query";
module: "system" | "ocp" | "routes" | "datamart" | "utils";
subModule: "db" | "labeling" | "printer" | "prodSql" | "query" | "sendmail";
level: "info" | "error" | "debug" | "fatal";
message: string;
data?: unknown[];
@@ -47,6 +47,9 @@ export const returnFunc = (data: Data) => {
return {
success: data.success,
message: data.message,
level: data.level,
module: data.module,
subModule: data.subModule,
data: data.data || [],
};
};

View File

@@ -0,0 +1,125 @@
import type { Transporter } from "nodemailer";
import nodemailer from "nodemailer";
import type Mail from "nodemailer/lib/mailer/index.js";
import type { Address } from "nodemailer/lib/mailer/index.js";
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
import hbs from "nodemailer-express-handlebars";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { promisify } from "util";
import { returnFunc } from "./returnHelper.utils.js";
import { tryCatch } from "./trycatch.utils.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) => {
let transporter: Transporter;
let fromEmail: string | Address;
if (os.hostname().includes("OLP")) {
transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD, // The 16-character App Password
},
//debug: true,
});
// update the from email
fromEmail = process.env.EMAIL_USER as string;
// update the from email
fromEmail = `noreply@alpla.com`;
} else {
//create the servers smtp config
let host = `${os.hostname().replace("VMS006", "")}-smtp.alpla.net`;
if (os.hostname().includes("VMS036")) {
host = "USMCD1-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`;
}
// create the handlebars view
const viewPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"./mailViews",
);
const handlebarOptions = {
viewEngine: {
extname: ".hbs",
defaultLayout: "",
partialsDir: viewPath,
},
viewPath: viewPath,
extName: ".hbs",
};
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("Blake's Test", "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);
const { data: mail, error } = await tryCatch(sendMailPromise(mailOptions));
if (mail) {
return returnFunc({
success: true,
level: "info",
module: "utils",
subModule: "sendmail",
message: `Email was sent to: ${data.email}`,
data: [],
notify: false,
});
}
if (error) {
return returnFunc({
success: false,
level: "error",
module: "utils",
subModule: "sendmail",
message: `Error sending Email to : ${data.email}`,
data: [{ error: error }],
notify: false,
});
}
};