baff7ce4db
Username lookups (login, admin seed, LDAP sync) compared case-sensitively against a stored value with whatever casing it was created with, so "Admin" and "admin" were treated as different accounts. Normalizes at every write and read path: UserService.create/update lowercase the username before persisting, findByUsername lowercases the lookup input, AuthService.validateUser lowercases before the login query, AdminSeedService lowercases the configured admin username, and the LDAP sync loop lowercases the mapped sAMAccountName before using it for lookup/create/update -- so AD casing differences don't create duplicate accounts either. Added a data migration to lowercase any existing mixed-case usernames. It relies on the User.username unique constraint to fail loudly if two existing accounts would collide after normalizing, rather than silently merging them. Verified locally: logged in with "ADMIN" (uppercase) against the existing lowercase "admin" account after rebuilding the API image. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
101 lines
2.3 KiB
TypeScript
101 lines
2.3 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import * as argon2 from 'argon2';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
@Injectable()
|
|
export class UserService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
/**
|
|
* Find user by username. Uses UNSCOPED Prisma (not tenant-scoped)
|
|
* because login must work across all tenants.
|
|
* Usernames are stored lowercase (case-insensitive login) -- normalize
|
|
* the lookup input to match regardless of how it was typed.
|
|
*/
|
|
async findByUsername(username: string) {
|
|
return this.prisma.user.findUnique({
|
|
where: { username: username.toLowerCase() },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find user by ID.
|
|
*/
|
|
async findById(id: string) {
|
|
return this.prisma.user.findUnique({ where: { id } });
|
|
}
|
|
|
|
/**
|
|
* Create a new user with hashed password.
|
|
* Username is normalized to lowercase so login is case-insensitive.
|
|
*/
|
|
async create(data: {
|
|
username: string;
|
|
email: string;
|
|
password?: string;
|
|
displayName?: string;
|
|
role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
|
tenantId: string;
|
|
mustChangePassword?: boolean;
|
|
ldapDn?: string;
|
|
}) {
|
|
const { password, ...rest } = data;
|
|
return this.prisma.user.create({
|
|
data: {
|
|
...rest,
|
|
username: rest.username.toLowerCase(),
|
|
passwordHash: password ? await argon2.hash(password) : null,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update user. If password is provided, hash it.
|
|
*/
|
|
async update(
|
|
id: string,
|
|
data: {
|
|
username?: string;
|
|
email?: string;
|
|
password?: string;
|
|
displayName?: string;
|
|
role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
|
isActive?: boolean;
|
|
mustChangePassword?: boolean;
|
|
},
|
|
) {
|
|
const { password, ...rest } = data;
|
|
const updateData: any = { ...rest };
|
|
|
|
if (updateData.username) {
|
|
updateData.username = updateData.username.toLowerCase();
|
|
}
|
|
|
|
if (password) {
|
|
updateData.passwordHash = await argon2.hash(password);
|
|
}
|
|
|
|
return this.prisma.user.update({
|
|
where: { id },
|
|
data: updateData,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deactivate a user (soft delete).
|
|
*/
|
|
async deactivate(id: string) {
|
|
return this.prisma.user.update({
|
|
where: { id },
|
|
data: { isActive: false },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hard delete a user.
|
|
*/
|
|
async delete(id: string) {
|
|
return this.prisma.user.delete({ where: { id } });
|
|
}
|
|
}
|