diff --git a/.vscode/lst.code-snippets b/.vscode/lst.code-snippets index ac1fa2e..29583d9 100644 --- a/.vscode/lst.code-snippets +++ b/.vscode/lst.code-snippets @@ -22,5 +22,27 @@ "\tsubModule: \"${2:start up}\",", "});" ] + }, + "Create Example Route Template":{ + "prefix": "createRoute", + "body":[ + "import { Router } from \"express\";", + "\timport { apiReturn } from \"../utils/returnHelper.utils.js\";", + + "\tconst r = Router();", + "\tr.post(\"/\", async (req, res) => {", + "\t", + "\tapiReturn(res, {", + "\tsuccess: true,", + "\tlevel: \"info\", //connect.success ? \"info\" : \"error\",", + "\tmodule: \"routes\",", + "\tsubModule: \"auth\",", + "\tmessage: \"Testing route\",", + "\tdata: [],", + "\tstatus: 200, //connect.success ? 200 : 400,", + "});", + "});", + "\texport default r;" + ] } } diff --git a/README.md b/README.md index 4a4b51f..09e7bd8 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ Quick summary of current rewrite/migration goal. | Feature | Description | Status | |----------|--------------|--------| -| User Authentication | Login, Signup, JWT refresh, API Key | 🟨 In Progress | +| User Authentication | ~~Login~~, ~~Signup~~, API Key | 🟨 In Progress | | User Profile | Edit profile, upload avatar | ⏳ Not Started | +| User Admin | Edit user, create user, remove user, alplaprod user integration | ⏳ Not Started | | Notifications | Subscribe, Create, Update | ⏳ Not Started | | Datamart | Create, Update, Run | 🔧 In Progress | | Frontend | Analytics and charts | ⏳ Not Started | -| One Click Print | Printing system | ⏳ Not Started | +| One Click Print | Get printers, monitor printers, label process, material process | ⏳ Not Started | | Silo Adjustments | Adjustments | ⏳ Not Started | | Demand Management | Orders, Forecast | ⏳ Not Started | | Open Docks | Integrations | ⏳ Not Started | diff --git a/backend/src/auth/auth.routes.ts b/backend/src/auth/auth.routes.ts new file mode 100644 index 0000000..abad45e --- /dev/null +++ b/backend/src/auth/auth.routes.ts @@ -0,0 +1,9 @@ +import type { Express } from "express"; +import login from "./login.route.js"; +import register from "./register.route.js"; + +export const setupAuthRoutes = (baseUrl: string, app: Express) => { + //setup all the routes + app.use(`${baseUrl}/api/authentication/login`, login); + app.use(`${baseUrl}/api/authentication/register`, register); +}; diff --git a/backend/src/auth/login.route.ts b/backend/src/auth/login.route.ts new file mode 100644 index 0000000..6c6a5cb --- /dev/null +++ b/backend/src/auth/login.route.ts @@ -0,0 +1,114 @@ +import { APIError } from "better-auth/api"; +import { fromNodeHeaders } from "better-auth/node"; +import { eq } from "drizzle-orm"; +import { Router } from "express"; +import z from "zod"; +import { db } from "../db/db.controller.js"; +import { user } from "../db/schema/auth.schema.js"; +import { auth } from "../utils/auth.utils.js"; +import { apiReturn } from "../utils/returnHelper.utils.js"; + +const base = { + password: z.string().min(8, "Password must be at least 8 characters"), +}; + +const signin = z.union([ + z.object({ + ...base, + email: z.email(), + username: z.undefined(), + }), + z.object({ + ...base, + username: z.string(), + email: z.undefined(), + }), +]); + +const r = Router(); + +r.post("/", async (req, res) => { + let login: unknown = []; + try { + const validated = signin.parse(req.body); + if ("email" in validated) { + login = await auth.api.signInEmail({ + body: { + email: validated.email as string, + password: validated.password, + }, + headers: fromNodeHeaders(req.headers), + }); + } + + if ("username" in validated) { + const getEmail = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.username, validated.username as string)); + + if (getEmail.length === 0) { + return apiReturn(res, { + success: false, + level: "error", //connect.success ? "info" : "error", + module: "routes", + subModule: "auth", + message: `${validated.username} dose not appear to be a valid username please try again`, + data: [], + status: 401, //connect.success ? 200 : 400, + }); + } + + // do the login with email + login = await auth.api.signInEmail({ + body: { + email: getEmail[0]?.email as string, + password: validated.password, + }, + headers: fromNodeHeaders(req.headers), + }); + } + + return apiReturn(res, { + success: true, + level: "info", //connect.success ? "info" : "error", + module: "routes", + subModule: "auth", + message: `Welcome back ${validated.username}`, + data: [login], + status: 200, //connect.success ? 200 : 400, + }); + } catch (err) { + if (err instanceof z.ZodError) { + const flattened = z.flattenError(err); + // return res.status(400).json({ + // error: "Validation failed", + // details: flattened, + // }); + + return apiReturn(res, { + success: false, + level: "error", //connect.success ? "info" : "error", + module: "routes", + subModule: "auth", + message: "Validation failed", + data: [flattened.fieldErrors], + status: 400, //connect.success ? 200 : 400, + }); + } + + if (err instanceof APIError) { + return apiReturn(res, { + success: false, + level: "error", //connect.success ? "info" : "error", + module: "routes", + subModule: "auth", + message: err.message, + data: [err.status], + status: 400, //connect.success ? 200 : 400, + }); + } + } +}); + +export default r; diff --git a/backend/src/auth/register.route.ts b/backend/src/auth/register.route.ts new file mode 100644 index 0000000..ab42079 --- /dev/null +++ b/backend/src/auth/register.route.ts @@ -0,0 +1,125 @@ +import { APIError } from "better-auth"; +import { count, sql } from "drizzle-orm"; +import { Router } from "express"; +import z from "zod"; +import { db } from "../db/db.controller.js"; +import { user } from "../db/schema/auth.schema.js"; +import { auth } from "../utils/auth.utils.js"; +import { apiReturn } from "../utils/returnHelper.utils.js"; + +const r = Router(); + +const registerSchema = z.object({ + email: z.email(), + name: z.string().min(2).max(100), + password: z.string().min(8, "Password must be at least 8 characters"), + username: z + .string() + .min(3) + .max(32) + .regex(/^[a-zA-Z0-9._]+$/, "Only alphanumeric, _, and ."), + displayUsername: z + .string() + .min(2) + .max(100) + .optional() + .describe("if you leave blank it will be the same as your username"), + role: z + .enum(["user"]) + .optional() + .describe("What roles are available to use."), + data: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "This allows us to add extra fields to the data to parse against", + ), +}); + +r.post("/", async (req, res) => { + // check if we are the first user so we can add as system admin to all modules + const totalUsers = await db.select({ count: count() }).from(user); + const userCount = totalUsers[0]?.count ?? 0; + try { + // validate the body is correct before accepting it + let validated = registerSchema.parse(req.body); + + validated = { + ...validated, + data: { lastLogin: new Date(Date.now()) }, + username: validated.username, + }; + + // Call Better Auth signUp + const newUser = await auth.api.signUpEmail({ + body: validated, + }); + + // if we have no users yet lets make this new one the admin + if (userCount === 0) { + // make this user an admin + await db.update(user).set({ role: "admin", updatedAt: sql`NOW()` }); + } + + apiReturn(res, { + success: true, + level: "info", //connect.success ? "info" : "error", + module: "routes", + subModule: "auth", + message: `${validated.username} was just created`, + data: [newUser], + status: 200, //connect.success ? 200 : 400, + }); + } catch (err) { + if (err instanceof z.ZodError) { + const flattened = z.flattenError(err); + // return res.status(400).json({ + // error: "Validation failed", + // details: flattened, + // }); + + apiReturn(res, { + success: false, + level: "error", //connect.success ? "info" : "error", + module: "routes", + subModule: "auth", + message: "Validation failed", + data: [flattened.fieldErrors], + status: 400, //connect.success ? 200 : 400, + }); + } + + if (err instanceof APIError) { + apiReturn(res, { + success: false, + level: "error", //connect.success ? "info" : "error", + module: "routes", + subModule: "auth", + message: err.message, + data: [err.status], + status: 400, //connect.success ? 200 : 400, + }); + } + + apiReturn(res, { + success: false, + level: "error", //connect.success ? "info" : "error", + module: "routes", + subModule: "auth", + message: "Internal Server Error creating user", + data: [err], + status: 400, //connect.success ? 200 : 400, + }); + } + + // apiReturn(res, { + // success: true, + // level: "info", //connect.success ? "info" : "error", + // module: "routes", + // subModule: "auth", + // message: "Testing route", + // data: [], + // status: 200, //connect.success ? 200 : 400, + // }); +}); +export default r; diff --git a/backend/src/routeHandler.routes.ts b/backend/src/routeHandler.routes.ts index 01862bc..60e59c2 100644 --- a/backend/src/routeHandler.routes.ts +++ b/backend/src/routeHandler.routes.ts @@ -1,15 +1,25 @@ import type { Express } from "express"; - +import { setupAuthRoutes } from "./auth/auth.routes.js"; // import the routes and route setups -// import { setupApiDocsRoutes } from "./configs/scaler.config.js"; -// import { setupDatamartRoutes } from "./datamart/datamart.routes.js"; -// import { setupProdSqlRoutes } from "./prodSql/prodSql.routes.js"; +import { setupApiDocsRoutes } from "./configs/scaler.config.js"; +import { setupDatamartRoutes } from "./datamart/datamart.routes.js"; +import { setupProdSqlRoutes } from "./prodSql/prodSql.routes.js"; import stats from "./system/stats.route.js"; export const setupRoutes = (baseUrl: string, app: Express) => { app.use(`${baseUrl}/api/stats`, stats); - //setup all the routes - // setupApiDocsRoutes(baseUrl, app); - // setupProdSqlRoutes(baseUrl, app); - // setupDatamartRoutes(baseUrl, app); + //routes that are on by default + setupApiDocsRoutes(baseUrl, app); + setupProdSqlRoutes(baseUrl, app); + setupDatamartRoutes(baseUrl, app); + setupAuthRoutes(baseUrl, app); + + // routes that get activated if the module is set to activated. + + app.all("*foo", (_, res) => { + res.status(400).json({ + message: + "You have encountered a route that dose not exist, please check the url and try again", + }); + }); }; diff --git a/backend/src/scaler/login.spec.ts b/backend/src/scaler/login.spec.ts new file mode 100644 index 0000000..eda19ad --- /dev/null +++ b/backend/src/scaler/login.spec.ts @@ -0,0 +1,122 @@ +import type { OpenAPIV3_1 } from "openapi-types"; + +export const prodLoginSpec: OpenAPIV3_1.PathsObject = { + "/api/authentication/login": { + post: { + summary: "Login", + description: + "Allows login by username and password and returns the user object to get the session data.", + tags: ["Auth"], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["username", "password"], + properties: { + username: { + type: "string", + example: "jdoe", + }, + password: { + type: "string", + format: "password", + example: "superSecretPassword", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "User info", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + + example: true, + }, + message: { + type: "string", + example: "Welcome back jdoe123", + }, + level: { + type: "string", + }, + module: { + type: "string", + }, + subModule: { + type: "string", + }, + data: { + type: "array", + items: { + type: "object", + properties: { + redirect: { type: "boolean" }, + token: { type: "string" }, + user: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + email: { type: "string", format: "email" }, + username: { type: "string" }, + role: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "401": { + description: "Login failed", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + + example: false, + }, + message: { + type: "string", + example: + "jdoe1234 dose not appear to be a valid username please try again", + }, + level: { + type: "string", + example: "error", + }, + module: { + type: "string", + example: "routes", + }, + subModule: { + type: "string", + example: "auth", + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/backend/src/scaler/register.spec.ts b/backend/src/scaler/register.spec.ts new file mode 100644 index 0000000..d902193 --- /dev/null +++ b/backend/src/scaler/register.spec.ts @@ -0,0 +1,87 @@ +import type { OpenAPIV3_1 } from "openapi-types"; + +export const prodRegisterSpec: OpenAPIV3_1.PathsObject = { + "/api/authentication/register": { + post: { + summary: "Register", + description: "Registers a new user.", + tags: ["Auth"], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["username", "password", "email"], + properties: { + username: { + type: "string", + example: "jdoe", + }, + name: { + type: "string", + format: "string", + example: "joe", + }, + email: { + type: "string", + format: "email", + example: "joe.doe@alpla.net", + }, + password: { + type: "string", + format: "password", + example: "superSecretPassword", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "User info", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + format: "true", + example: true, + }, + message: { + type: "string", + example: "User was created", + }, + }, + }, + }, + }, + }, + "400": { + description: "Invalid Data was sent over", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + format: "false", + example: false, + }, + message: { + type: "string", + format: "Invalid Data was sent over.", + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/backend/src/utils/auth.utils.ts b/backend/src/utils/auth.utils.ts index f5f0a0e..c445ce2 100644 --- a/backend/src/utils/auth.utils.ts +++ b/backend/src/utils/auth.utils.ts @@ -1,6 +1,12 @@ import { betterAuth, type User } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { admin, apiKey, jwt, username } from "better-auth/plugins"; +import { + admin, + apiKey, + customSession, + 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"; @@ -21,15 +27,52 @@ export const auth = betterAuth({ baseURL: process.env.URL, database: drizzleAdapter(db, { provider: "pg", + schema, }), + user: { + additionalFields: { + role: { + type: "string", + required: false, + input: false, + }, + lastLogin: { + type: "date", + required: false, + input: false, + }, + }, + }, plugins: [ jwt({ jwt: { expirationTime: "1h" } }), apiKey(), admin(), - username(), + username({ + minUsernameLength: 5, + usernameValidator: (username) => { + if (username === "admin") { + return false; + } + return true; + }, + }), + customSession(async ({ user, session }) => { + const roles = await db + .select({ roles: rawSchema.user.role }) + .from(rawSchema.user) + .where(eq(rawSchema.user.id, session.id)); + return { + roles, + user: { + ...user, + //newField: "newField", + }, + session, + }; + }), ], trustedOrigins: allowedOrigins, - // email or username and password. + emailAndPassword: { enabled: true, minPasswordLength: 8, // optional config diff --git a/backend/src/utils/returnHelper.utils.ts b/backend/src/utils/returnHelper.utils.ts index 726f303..a8c0bf5 100644 --- a/backend/src/utils/returnHelper.utils.ts +++ b/backend/src/utils/returnHelper.utils.ts @@ -4,7 +4,14 @@ import { createLogger } from "../logger/logger.controller.js"; interface Data { success: boolean; module: "system" | "ocp" | "routes" | "datamart" | "utils"; - subModule: "db" | "labeling" | "printer" | "prodSql" | "query" | "sendmail"; + subModule: + | "db" + | "labeling" + | "printer" + | "prodSql" + | "query" + | "sendmail" + | "auth"; level: "info" | "error" | "debug" | "fatal"; message: string; data?: unknown[]; diff --git a/package-lock.json b/package-lock.json index 764dac4..94cac5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.1", "license": "ISC", "dependencies": { - "@dotenvx/dotenvx": "^1.51.2" + "@dotenvx/dotenvx": "^1.51.2", + "zod": "^4.2.1" }, "devDependencies": { "@biomejs/biome": "2.3.8", @@ -16338,10 +16339,9 @@ } }, "node_modules/zod": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", - "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", - "dev": true, + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index a4842bd..2e5b7e6 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "vite-tsconfig-paths": "^6.0.3", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "zod": "^4.2.1" }, "dependencies": { "@dotenvx/dotenvx": "^1.51.2" diff --git a/tsconfig.json b/tsconfig.json index 7f19c24..c0719f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "moduleResolution": "nodenext", "strict": true, "verbatimModuleSyntax": true, - "types": ["node", "better-auth", "jest"], + "types": ["node", "better-auth"], "jsx": "react-jsx", "outDir": "./dist", "removeComments": true, @@ -38,5 +38,4 @@ "lstDocs", "database/testFiles", "scripts" - ] -} + ]} \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json deleted file mode 100644 index 3948254..0000000 --- a/tsconfig.test.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "bundler", - "target": "ESNext", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true - }, - "include": ["tests/**/*", "backend/**/*"] -} \ No newline at end of file