feat(auth): align login and bootstrap with new user schema

This commit is contained in:
2026-06-19 01:05:33 +02:00
parent 2ed9445f7f
commit 01d89cd21b
10 changed files with 503 additions and 60 deletions
+90 -17
View File
@@ -1,4 +1,6 @@
import { fileURLToPath } from "node:url"
import { UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { getPasswordHash } from "@/lib/security"
import prisma from "../src/lib/prisma"
@@ -8,10 +10,19 @@ type BootstrapAdminInput = {
password: string
}
function splitName(name: string) {
const [firstName = "Administrator", ...rest] = name.trim().split(/\s+/)
return {
firstName,
lastName: rest.join(" "),
}
}
function getBootstrapAdminInput(): BootstrapAdminInput {
const isProduction = process.env.NODE_ENV === "production"
const email = process.env.ADMIN_EMAIL ?? "admin@localhost"
const email = process.env.ADMIN_EMAIL ?? "admin@local.host"
const name = process.env.ADMIN_NAME ?? "Administrator"
const password = process.env.ADMIN_PASSWORD
@@ -28,36 +39,98 @@ function getBootstrapAdminInput(): BootstrapAdminInput {
export async function bootstrapAdmin(client: typeof prisma) {
const enabled = process.env.ADMIN_BOOTSTRAP_ENABLED !== "false"
const existingAdmin = await client.user.findFirst({
if (!enabled) return
const admin = getBootstrapAdminInput()
const email = normalizeEmail(admin.email)
const { firstName, lastName } = splitName(admin.name)
const existingUser = await client.user.findUnique({
where: {
role: "ADMIN",
isActive: true,
emailNormalized: email,
},
select: {
id: true,
passwordHash: true,
activatedAt: true,
person: {
select: {
id: true,
},
},
},
})
if (existingAdmin || !enabled) return
const admin = getBootstrapAdminInput()
await client.user.upsert({
const user = existingUser
? await client.user.update({
where: {
email: admin.email,
id: existingUser.id,
},
update: {
role: "ADMIN",
isActive: true,
},
create: {
data: {
name: admin.name,
email: admin.email,
emailNormalized: email,
role: "ADMIN",
password: await getPasswordHash(admin.password),
isActive: true,
status: UserStatus.ACTIVE,
...(existingUser.passwordHash
? {}
: {
passwordHash: await getPasswordHash(admin.password),
passwordChangedAt: new Date(),
}),
...(existingUser.activatedAt ? {} : { activatedAt: new Date() }),
},
select: {
id: true,
person: {
select: {
id: true,
},
},
},
})
: await client.user.create({
data: {
name: admin.name,
email: admin.email,
emailNormalized: email,
role: "ADMIN",
status: UserStatus.ACTIVE,
passwordHash: await getPasswordHash(admin.password),
activatedAt: new Date(),
passwordChangedAt: new Date(),
},
select: {
id: true,
person: {
select: {
id: true,
},
},
},
})
if (!user.person) {
await client.person.upsert({
where: {
userId: user.id,
},
update: {
firstName,
lastName,
email: admin.email,
},
create: {
firstName,
lastName,
email: admin.email,
user: {
connect: {
id: user.id,
},
},
},
})
}
}
async function main() {
+9 -6
View File
@@ -2,11 +2,12 @@ import NextAuth, { type DefaultSession } from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { ZodError } from "zod"
import type { UserRole } from "@/generated/prisma/client"
import { type UserRole, UserStatus } from "@/generated/prisma/client"
import { SIGN_IN_URL, TOKEN_EXPIRATION_SECONDS } from "@/lib/constants"
import { normalizeEmail } from "@/lib/email"
import { verifyPassword } from "@/lib/security"
import { signInSchema } from "@/schemas/auth.schema"
import { getUserByEmail } from "@/services/user.service"
import { getUserCredentialsByEmail } from "@/services/user.service"
declare module "next-auth" {
interface Session {
@@ -38,21 +39,23 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (!success) throw new Error("Invalid email or password")
const user = await getUserByEmail(data.email)
const user = await getUserCredentialsByEmail(normalizeEmail(data.email))
if (!user) {
throw new Error("Invalid email or password")
}
if (!user.isActive) {
if (user.status !== UserStatus.ACTIVE || !user.passwordHash) {
throw new Error("Invalid email or password")
}
if (!(await verifyPassword(data.password, user.password)))
if (!(await verifyPassword(data.password, user.passwordHash)))
throw new Error("Invalid email or password")
return {
...user,
id: user.id,
name: user.name,
email: user.email,
role: user.role,
}
} catch (error) {
+3
View File
@@ -0,0 +1,3 @@
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod"
export const signInSchema = z.object({
email: z.email().nonempty("Email is required"),
email: z.string().trim().toLowerCase().email().nonempty("Email is required"),
password: z
.string()
.min(3, {
+97 -17
View File
@@ -1,4 +1,5 @@
import { type Prisma, UserRole } from "@/generated/prisma/client"
import { type Prisma, UserRole, UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
import { getPasswordHash } from "@/lib/security"
@@ -10,16 +11,21 @@ const userWithoutPasswordSelect = {
name: true,
email: true,
role: true,
isActive: true,
status: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.UserSelect
export type UserWithoutPassword = Prisma.UserGetPayload<{
type UserWithoutPasswordPayload = Prisma.UserGetPayload<{
select: typeof userWithoutPasswordSelect
}>
type CreateUserData = Pick<User, "name" | "email" | "password"> & {
export type UserWithoutPassword = UserWithoutPasswordPayload & {
isActive: boolean
}
type CreateUserData = Pick<User, "name" | "email"> & {
password: string
role?: UserRole
isActive?: boolean
}
@@ -37,44 +43,94 @@ type GetUsersParams = {
isActive?: boolean
}
function userStatusFromActive(isActive: boolean | undefined) {
if (typeof isActive !== "boolean") return undefined
return isActive ? UserStatus.ACTIVE : UserStatus.DISABLED
}
function toUserWithoutPassword(
user: UserWithoutPasswordPayload,
): UserWithoutPassword {
return {
...user,
isActive: user.status === UserStatus.ACTIVE,
}
}
export async function createUser(
{ data }: { data: CreateUserData },
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
const status = userStatusFromActive(data.isActive) ?? UserStatus.ACTIVE
const passwordHash = await getPasswordHash(data.password)
const now = new Date()
const newUser = await db.user.create({
data: {
name: data.name,
email: data.email,
password: await getPasswordHash(data.password),
emailNormalized: normalizeEmail(data.email),
passwordHash,
role: data.role ?? UserRole.STAFF,
isActive: data.isActive ?? true,
status,
...(status === UserStatus.ACTIVE ? { activatedAt: now } : {}),
passwordChangedAt: now,
},
select: userWithoutPasswordSelect,
})
return newUser
return toUserWithoutPassword(newUser)
}
export async function getUserById(
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
return await db.user.findUnique({ where: { id } })
const user = await db.user.findUnique({
where: { id },
select: userWithoutPasswordSelect,
})
return user ? toUserWithoutPassword(user) : null
}
export async function getUserProfileById(
id: string,
): Promise<UserWithoutPassword | null> {
return prisma.user.findUnique({
const user = await prisma.user.findUnique({
where: { id },
select: userWithoutPasswordSelect,
})
return user ? toUserWithoutPassword(user) : null
}
export async function getUserByEmail(
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
return await db.user.findUnique({ where: { email } })
const user = await db.user.findUnique({
where: { emailNormalized: normalizeEmail(email) },
select: userWithoutPasswordSelect,
})
return user ? toUserWithoutPassword(user) : null
}
export async function getUserCredentialsByEmail(
email: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
) {
return await db.user.findUnique({
where: { emailNormalized: normalizeEmail(email) },
select: {
id: true,
name: true,
email: true,
role: true,
status: true,
passwordHash: true,
},
})
}
export async function getUsers({
@@ -84,12 +140,14 @@ export async function getUsers({
role,
isActive,
}: GetUsersParams = {}): Promise<PaginatedResult<UserWithoutPassword>> {
return paginate<UserWithoutPassword>({
const users = await paginate<UserWithoutPasswordPayload>({
model: prisma.user,
page,
pageSize,
where: {
...(typeof isActive === "boolean" ? { isActive } : {}),
...(typeof isActive === "boolean"
? { status: userStatusFromActive(isActive) }
: {}),
...(role ? { role } : {}),
...(search
? {
@@ -103,6 +161,11 @@ export async function getUsers({
orderBy: { createdAt: "desc" },
select: userWithoutPasswordSelect,
})
return {
...users,
data: users.data.map(toUserWithoutPassword),
}
}
export async function updateUser(
@@ -110,11 +173,25 @@ export async function updateUser(
data: UpdateUserData,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<UserWithoutPassword> {
return db.user.update({
const user = await db.user.update({
where: { id },
data,
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.email !== undefined
? { email: data.email, emailNormalized: normalizeEmail(data.email) }
: {}),
...(data.role !== undefined ? { role: data.role } : {}),
...(data.isActive !== undefined
? {
status: userStatusFromActive(data.isActive),
...(data.isActive ? { activatedAt: new Date() } : {}),
}
: {}),
},
select: userWithoutPasswordSelect,
})
return toUserWithoutPassword(user)
}
export async function updateUserRole(
@@ -137,13 +214,16 @@ export async function resetUserPassword(
password: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<UserWithoutPassword> {
return db.user.update({
const user = await db.user.update({
where: { id },
data: {
password: await getPasswordHash(password),
passwordHash: await getPasswordHash(password),
passwordChangedAt: new Date(),
},
select: userWithoutPasswordSelect,
})
return toUserWithoutPassword(user)
}
export async function countActiveAdmins(
@@ -152,7 +232,7 @@ export async function countActiveAdmins(
return db.user.count({
where: {
role: UserRole.ADMIN,
isActive: true,
status: UserStatus.ACTIVE,
},
})
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Prisma } from "@/generated/prisma/client"
import { Prisma, UserStatus } from "@/generated/prisma/client"
import prisma from "@/lib/prisma"
import type {
CreateUserFormType,
@@ -96,7 +96,7 @@ async function getAdminAccessLossError(
if (
user.role === "ADMIN" &&
user.isActive &&
user.status === UserStatus.ACTIVE &&
(await countActiveAdmins(db)) <= 1
) {
return "Cannot remove access from the last active administrator"
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
import { createTestUser } from "../helpers/factories"
import {
resetIntegrationTestDatabase,
@@ -51,18 +52,21 @@ describe("user use-cases", () => {
expect(result).toEqual({ success: true })
const user = await prisma.user.findUniqueOrThrow({
where: { email: "new-user@example.test" },
where: { emailNormalized: normalizeEmail("new-user@example.test") },
})
expect(user).toMatchObject({
name: "New User",
email: "new-user@example.test",
role: "STAFF",
isActive: true,
status: "ACTIVE",
})
expect(user.password).not.toBe("secure-password")
expect(user.activatedAt).toBeInstanceOf(Date)
expect(user.passwordChangedAt).toBeInstanceOf(Date)
expect(user.passwordHash).not.toBe("secure-password")
if (!user.passwordHash) throw new Error("Expected password hash")
await expect(
verifyPassword("secure-password", user.password),
verifyPassword("secure-password", user.passwordHash),
).resolves.toBe(true)
})
@@ -87,7 +91,7 @@ describe("user use-cases", () => {
await expect(prisma.user.count()).resolves.toBe(1)
await expect(
prisma.user.findUniqueOrThrow({
where: { email: "existing@example.test" },
where: { emailNormalized: normalizeEmail("existing@example.test") },
}),
).resolves.toMatchObject({ email: "existing@example.test" })
})
@@ -120,7 +124,7 @@ describe("user use-cases", () => {
name: "Edited User",
email: "edited@example.test",
role: "MANAGER",
isActive: true,
status: "ACTIVE",
})
await expect(
@@ -163,7 +167,7 @@ describe("user use-cases", () => {
await expect(
prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).resolves.toMatchObject({ role: "ADMIN", isActive: true })
).resolves.toMatchObject({ role: "ADMIN", status: "ACTIVE" })
})
it("protects the last active administrator but allows deactivation when another active admin exists", async () => {
@@ -204,7 +208,7 @@ describe("user use-cases", () => {
await expect(
prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }),
).resolves.toMatchObject({ isActive: false })
).resolves.toMatchObject({ status: "DISABLED" })
})
it("prevents self-deactivation", async () => {
@@ -224,7 +228,7 @@ describe("user use-cases", () => {
await expect(
prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).resolves.toMatchObject({ isActive: true })
).resolves.toMatchObject({ status: "ACTIVE" })
})
it("resets a user password and rejects missing users", async () => {
@@ -244,9 +248,10 @@ describe("user use-cases", () => {
where: { id: user.id },
})
expect(updatedUser.password).not.toBe(user.password)
expect(updatedUser.passwordHash).not.toBe(user.passwordHash)
if (!updatedUser.passwordHash) throw new Error("Expected password hash")
await expect(
verifyPassword("new-secure-password", updatedUser.password),
verifyPassword("new-secure-password", updatedUser.passwordHash),
).resolves.toBe(true)
await expect(
+164
View File
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
getPasswordHash: vi.fn(),
}))
vi.mock("@/lib/security", () => ({
getPasswordHash: mocks.getPasswordHash,
}))
vi.mock("../../../src/lib/prisma", () => ({
default: {},
}))
import { bootstrapAdmin } from "../../../prisma/bootstrap-admin"
describe("bootstrapAdmin", () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.ADMIN_BOOTSTRAP_ENABLED = "true"
process.env.ADMIN_EMAIL = "Admin@Example.Test"
process.env.ADMIN_NAME = "E2E Admin"
process.env.ADMIN_PASSWORD = "admin-password"
vi.stubEnv("NODE_ENV", "development")
mocks.getPasswordHash.mockResolvedValue("hashed-password")
})
it("creates an active user and links a person on first run", async () => {
const userFindUnique = vi.fn().mockResolvedValue(null)
const userCreate = vi.fn().mockResolvedValue({
id: "user-1",
person: null,
})
const userUpdate = vi.fn()
const personUpsert = vi.fn().mockResolvedValue({ id: "person-1" })
const client = {
user: {
findUnique: userFindUnique,
create: userCreate,
update: userUpdate,
},
person: {
upsert: personUpsert,
},
}
await bootstrapAdmin(client as never)
expect(userFindUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: {
emailNormalized: "admin@example.test",
},
}),
)
expect(userCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
emailNormalized: "admin@example.test",
passwordHash: "hashed-password",
status: "ACTIVE",
}),
}),
)
expect(userUpdate).not.toHaveBeenCalled()
expect(personUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: "user-1",
},
create: expect.objectContaining({
firstName: "E2E",
lastName: "Admin",
email: "Admin@Example.Test",
}),
update: expect.objectContaining({
firstName: "E2E",
lastName: "Admin",
email: "Admin@Example.Test",
}),
}),
)
})
it("is idempotent when the admin already has a linked person", async () => {
const userFindUnique = vi.fn().mockResolvedValue({
id: "user-1",
passwordHash: "existing-hash",
activatedAt: new Date("2024-01-01T00:00:00.000Z"),
person: { id: "person-1" },
})
const userCreate = vi.fn()
const userUpdate = vi.fn().mockResolvedValue({
id: "user-1",
person: { id: "person-1" },
})
const personUpsert = vi.fn()
const client = {
user: {
findUnique: userFindUnique,
create: userCreate,
update: userUpdate,
},
person: {
upsert: personUpsert,
},
}
await bootstrapAdmin(client as never)
expect(mocks.getPasswordHash).not.toHaveBeenCalled()
expect(userCreate).not.toHaveBeenCalled()
expect(userUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.not.objectContaining({
passwordHash: expect.any(String),
activatedAt: expect.any(Date),
passwordChangedAt: expect.any(Date),
}),
}),
)
expect(personUpsert).not.toHaveBeenCalled()
})
it("links a missing person without rehashing an existing admin password", async () => {
const userFindUnique = vi.fn().mockResolvedValue({
id: "user-1",
passwordHash: "existing-hash",
activatedAt: new Date("2024-01-01T00:00:00.000Z"),
person: null,
})
const userCreate = vi.fn()
const userUpdate = vi.fn().mockResolvedValue({
id: "user-1",
person: null,
})
const personUpsert = vi.fn().mockResolvedValue({ id: "person-1" })
const client = {
user: {
findUnique: userFindUnique,
create: userCreate,
update: userUpdate,
},
person: {
upsert: personUpsert,
},
}
await bootstrapAdmin(client as never)
expect(mocks.getPasswordHash).not.toHaveBeenCalled()
expect(userCreate).not.toHaveBeenCalled()
expect(personUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: "user-1",
},
}),
)
})
})
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"
import { signInSchema } from "@/schemas/auth.schema"
describe("signInSchema", () => {
it("normalizes login emails before authentication", () => {
const result = signInSchema.safeParse({
email: " Admin@Example.Test ",
password: "secret-password",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.email).toBe("admin@example.test")
}
})
})
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest"
vi.mock("@/lib/prisma", () => ({
default: {},
}))
import {
getUserById,
getUserByEmail,
getUserCredentialsByEmail,
} from "@/services/user.service"
describe("getUserById", () => {
it("does not select passwordHash across the broad user lookup boundary", async () => {
const findUnique = vi.fn().mockResolvedValue(null)
const db = {
user: {
findUnique,
},
}
await getUserById("user-1", db as never)
expect(findUnique).toHaveBeenCalledWith({
where: {
id: "user-1",
},
select: expect.not.objectContaining({
passwordHash: true,
}),
})
})
})
describe("getUserByEmail", () => {
it("queries emailNormalized with a normalized email", async () => {
const findUnique = vi.fn().mockResolvedValue(null)
const db = {
user: {
findUnique,
},
}
await getUserByEmail(" Admin@Example.Test ", db as never)
expect(findUnique).toHaveBeenCalledWith({
where: {
emailNormalized: "admin@example.test",
},
select: expect.not.objectContaining({
passwordHash: true,
}),
})
})
it("does not return passwordHash across the broad user lookup boundary", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "user-1",
name: "Admin",
email: "admin@example.test",
role: "ADMIN",
status: "ACTIVE",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
})
const db = {
user: {
findUnique,
},
}
const user = await getUserByEmail("admin@example.test", db as never)
expect(user).not.toHaveProperty("passwordHash")
})
})
describe("getUserCredentialsByEmail", () => {
it("selects passwordHash only for credential verification", async () => {
const findUnique = vi.fn().mockResolvedValue(null)
const db = {
user: {
findUnique,
},
}
await getUserCredentialsByEmail("Admin@Example.Test", db as never)
expect(findUnique).toHaveBeenCalledWith({
where: {
emailNormalized: "admin@example.test",
},
select: expect.objectContaining({
passwordHash: true,
}),
})
})
})