feat: add User password hashing + account creation script

This commit is contained in:
2026-07-31 11:51:08 +07:00
parent 41de13b1ff
commit 43a20d8530
4 changed files with 219 additions and 1 deletions
+150
View File
@@ -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<UserRecord> {
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<UserRecord | null> {
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);
}