diff --git a/package-lock.json b/package-lock.json index a7532cd..69a829c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@prisma/client": "^5.20.0", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.28.0", @@ -5210,6 +5211,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", diff --git a/package.json b/package.json index b50a45c..e416e8f 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,12 @@ "lint": "next lint", "test": "vitest run", "postinstall": "prisma generate", - "db:migrate": "tsx scripts/migrate.ts" + "db:migrate": "tsx scripts/migrate.ts", + "user:create": "tsx scripts/create-user.ts" }, "dependencies": { "@prisma/client": "^5.20.0", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.28.0", diff --git a/scripts/create-user.ts b/scripts/create-user.ts new file mode 100644 index 0000000..ecb72a7 --- /dev/null +++ b/scripts/create-user.ts @@ -0,0 +1,56 @@ +// Standalone CLI entry point for creating an internal auth account by hand: +// `npx tsx scripts/create-user.ts --email foo@company.com --name "Foo Bar" --password "..."` +// There is deliberately no self-registration UI or public API route for +// this — see src/lib/users.ts / prisma/schema.prisma's User model comment +// for why (a handful of trusted IT staff accounts, not company-wide). +import { createUser, validateCreateUserInput } from "../src/lib/users"; +import { prisma } from "../src/lib/prisma"; + +function parseArgs(argv: string[]): Record { + const args: Record = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + continue; + } + const key = arg.slice(2); + const value = argv[i + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`Missing value for --${key}`); + } + args[key] = value; + i += 1; + } + return args; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + const validation = validateCreateUserInput({ + email: args.email, + name: args.name, + password: args.password, + }); + + if (!validation.ok) { + console.error(`Error: ${validation.error}`); + console.error( + 'Usage: npx tsx scripts/create-user.ts --email foo@company.com --name "Foo Bar" --password "..."' + ); + process.exitCode = 1; + return; + } + + const user = await createUser(validation.value); + console.log(`Created user ${user.email} (id: ${user.id})`); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/lib/users.ts b/src/lib/users.ts new file mode 100644 index 0000000..ec1da71 --- /dev/null +++ b/src/lib/users.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; +import bcrypt from "bcryptjs"; +import { prisma } from "./prisma"; + +// Minimal internal auth accounts — see prisma/schema.prisma's User model +// comment and CLAUDE.md's auth decision notes. A handful of trusted IT +// staff accounts, created by hand via scripts/create-user.ts. No +// self-registration, no `role` field (flat permission model: any logged-in +// user can do anything), no public list/CRUD API route — just enough to +// support login + account creation. + +// bcryptjs cost factor for hashing. 10 is bcrypt's long-standing default +// and a reasonable balance of hashing cost vs. login latency for this use +// case (a handful of staff logins, not a high-value target). +const BCRYPT_SALT_ROUNDS = 10; + +// Deliberately chosen minimum — long enough to rule out trivially weak +// passwords for a handful of trusted IT staff accounts, not a full password +// policy (no complexity rules, no breach-list check). Revisit if this ever +// grows into employee self-service. +const MIN_PASSWORD_LENGTH = 8; + +export interface UserRecord { + id: string; + email: string; + name: string; + created_at: string; +} + +export interface CreateUserInput { + email: string; + name: string; + password: string; +} + +export type ValidationResult = + | { ok: true; value: CreateUserInput } + | { ok: false; error: string }; + +// A string field that must be present and not just whitespace. Mirrors the +// same helper in src/lib/requests.ts (not shared/exported from there since +// that file doesn't export it — small enough to duplicate rather than +// introduce a shared-helpers module for one function). +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +const createUserSchema = z.object({ + email: z + .string({ required_error: "email is required", invalid_type_error: "email is required" }) + .email("email must be a valid email address"), + name: nonEmptyString("name is required and must be a non-empty string"), + password: z + .string({ + required_error: `password is required and must be at least ${MIN_PASSWORD_LENGTH} characters`, + invalid_type_error: `password is required and must be at least ${MIN_PASSWORD_LENGTH} characters`, + }) + .min( + MIN_PASSWORD_LENGTH, + `password is required and must be at least ${MIN_PASSWORD_LENGTH} characters` + ), +}); + +/** + * Validates raw (untyped) user-creation input. email must be a valid email + * address, name must be a non-empty string, password must be at least + * MIN_PASSWORD_LENGTH characters. The password is validated here in its + * plaintext form — hashing happens separately in createUser(). + */ +export function validateCreateUserInput(input: { + email?: unknown; + name?: unknown; + password?: unknown; +}): ValidationResult { + const result = createUserSchema.safeParse(input); + if (!result.success) { + const message = result.error.issues[0]?.message ?? "Invalid input"; + return { ok: false, error: message }; + } + return { ok: true, value: result.data }; +} + +function toRecord(row: { + id: string; + email: string; + name: string; + createdAt: Date; +}): UserRecord { + return { + id: row.id, + email: row.email, + name: row.name, + created_at: row.createdAt.toISOString(), + }; +} + +/** + * Hashes the given plaintext password with bcryptjs and inserts a new User + * row. Caller is expected to have already validated the input via + * validateCreateUserInput() — this does not re-validate. + */ +export async function createUser(input: CreateUserInput): Promise { + const passwordHash = await bcrypt.hash(input.password, BCRYPT_SALT_ROUNDS); + const created = await prisma.user.create({ + data: { + email: input.email, + name: input.name, + passwordHash, + }, + }); + return toRecord(created); +} + +/** + * Looks up a user by email and compares the given plaintext password + * against the stored bcrypt hash. Returns the user record (never the + * password hash) on success, or null if the email doesn't exist or the + * password doesn't match — callers should treat both failure cases + * identically (don't leak which one it was). + * + * Accepts `unknown` for email/password since this is also the core logic + * behind the Credentials provider's authorize() callback, whose input + * comes from unvalidated form/request data — see src/auth.ts. + */ +export async function verifyUserCredentials( + email: unknown, + password: unknown +): Promise { + if (typeof email !== "string" || typeof password !== "string") { + return null; + } + if (email.trim() === "" || password === "") { + return null; + } + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + return null; + } + + const passwordMatches = await bcrypt.compare(password, user.passwordHash); + if (!passwordMatches) { + return null; + } + + return toRecord(user); +}