Two follow-ups to the text-size control. Spacing now scales with the text. All padding, margin, gap and min-height declarations in globals.css move from px to rem (263 declarations, converted mechanically), so --ui-scale drives the whole layout rather than just the glyphs. Deliberately left in px: border widths, which must stay hairlines; box-shadow offsets; border-radius, which reads as bloated when scaled on large cards; --shell-max, a container cap that must not outgrow the viewport; and media-query breakpoints, which are conditions rather than declarations. With spacing following along, the presets gain a 1.5 "Máximo" step and MAX_UI_SCALE rises from 1.4. The preference now lives on the account instead of only in one browser. User.uiScale (Float, default 1) is added to the schema and to the safe select, so it rides along on /auth/login and /auth/me. PATCH /auth/preferences writes it, guarded by AuthenticatedGuard only — every role including VIEWER may set their own, and the target is always the session's user id, never a body parameter, so this cannot be used to touch another account. The global ValidationPipe's whitelist rejects any extra field, so role cannot ride in alongside uiScale. localStorage stays, demoted to a pre-paint cache for the layout.tsx script; AppShell reconciles it against the account once /auth/me answers, with the account winning. FontScaleControl becomes a controlled component since the same value is now edited from the appbar and the drawer. Verified against the dev API: PATCH persists and is reflected by a subsequent /auth/me, out-of-range values are rejected 400, and an extra "role" field in the body is rejected 400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
4.5 KiB
TypeScript
163 lines
4.5 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import * as argon2 from "argon2";
|
|
import { Prisma } from "@jorgecuadros/database";
|
|
import type { User } from "@jorgecuadros/database";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { CreateUserDto } from "./create-user.dto";
|
|
import { UpdateUserDto } from "./update-user.dto";
|
|
|
|
/** Shape returned to the UI — never carries passwordHash. */
|
|
const safeSelect = {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
active: true,
|
|
uiScale: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} satisfies Prisma.UserSelect;
|
|
|
|
export type SafeUserRow = Prisma.UserGetPayload<{ select: typeof safeSelect }>;
|
|
|
|
@Injectable()
|
|
export class UsersService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
// --- used by auth (need the hash / full row) -----------------------------
|
|
|
|
findByEmail(email: string): Promise<User | null> {
|
|
return this.prisma.user.findUnique({ where: { email } });
|
|
}
|
|
|
|
findById(id: string): Promise<User | null> {
|
|
return this.prisma.user.findUnique({ where: { id } });
|
|
}
|
|
|
|
// --- admin CRUD (safe rows only) -----------------------------------------
|
|
|
|
list(): Promise<SafeUserRow[]> {
|
|
return this.prisma.user.findMany({
|
|
orderBy: [{ active: "desc" }, { name: "asc" }],
|
|
select: safeSelect,
|
|
});
|
|
}
|
|
|
|
async create(dto: CreateUserDto): Promise<SafeUserRow> {
|
|
const passwordHash = await argon2.hash(dto.password);
|
|
try {
|
|
return await this.prisma.user.create({
|
|
data: {
|
|
name: dto.name,
|
|
email: dto.email,
|
|
passwordHash,
|
|
role: dto.role,
|
|
active: dto.active ?? true,
|
|
},
|
|
select: safeSelect,
|
|
});
|
|
} catch (e) {
|
|
throw this.mapError(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `actingUserId` is the admin making the change — used to stop an admin from
|
|
* locking themselves out (deactivating or demoting their own account).
|
|
*/
|
|
async update(
|
|
id: string,
|
|
dto: UpdateUserDto,
|
|
actingUserId: string,
|
|
): Promise<SafeUserRow> {
|
|
await this.ensureExists(id);
|
|
|
|
if (id === actingUserId) {
|
|
if (dto.active === false) {
|
|
throw new BadRequestException("No puede desactivar su propia cuenta");
|
|
}
|
|
if (dto.role && dto.role !== "ADMIN") {
|
|
throw new BadRequestException("No puede quitarse su propio rol de administrador");
|
|
}
|
|
}
|
|
|
|
try {
|
|
return await this.prisma.user.update({
|
|
where: { id },
|
|
data: {
|
|
name: dto.name,
|
|
email: dto.email,
|
|
role: dto.role,
|
|
active: dto.active,
|
|
},
|
|
select: safeSelect,
|
|
});
|
|
} catch (e) {
|
|
throw this.mapError(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Self-service preference write — no ability check, because the only account
|
|
* it can touch is the caller's own (the controller passes the session id).
|
|
*/
|
|
updatePreferences(id: string, uiScale: number): Promise<SafeUserRow> {
|
|
return this.prisma.user.update({
|
|
where: { id },
|
|
data: { uiScale },
|
|
select: safeSelect,
|
|
});
|
|
}
|
|
|
|
async resetPassword(id: string, password: string): Promise<SafeUserRow> {
|
|
await this.ensureExists(id);
|
|
const passwordHash = await argon2.hash(password);
|
|
return this.prisma.user.update({
|
|
where: { id },
|
|
data: { passwordHash },
|
|
select: safeSelect,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hard-delete a user. The schema's ActivityLog.userId FK would otherwise
|
|
* block the row (default `Restrict`), so null it out in the same
|
|
* transaction. Rows + the actor id captured in the `message` JSON stay
|
|
* intact for the audit trail.
|
|
*/
|
|
async remove(id: string, actingUserId: string): Promise<void> {
|
|
if (id === actingUserId) {
|
|
throw new BadRequestException("No puede eliminar su propia cuenta");
|
|
}
|
|
await this.ensureExists(id);
|
|
try {
|
|
await this.prisma.$transaction([
|
|
this.prisma.activityLog.updateMany({
|
|
where: { userId: id },
|
|
data: { userId: null },
|
|
}),
|
|
this.prisma.user.delete({ where: { id } }),
|
|
]);
|
|
} catch (e) {
|
|
throw this.mapError(e);
|
|
}
|
|
}
|
|
|
|
private async ensureExists(id: string): Promise<void> {
|
|
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
|
|
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
|
|
}
|
|
|
|
private mapError(e: unknown): Error {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
|
return new ConflictException("Ya existe un usuario con ese correo");
|
|
}
|
|
return e as Error;
|
|
}
|
|
}
|