Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/db/users.db.server.ts
T

720 lines
18 KiB
TypeScript

import { AdminUser, Role, Tenant, TenantUser, UserRole } from "@toolctrl/db/prisma-browser";
import { consumeRecoveryCode, getRecoveryCodeStatus, RecoveryCodeHashEntry } from "~/utils/auth/mfa/recovery-codes.server";
import { hashPassword } from "~/utils/auth/passwordHasher.server";
import Constants from "~/application/Constants";
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
import { db } from "~/utils/db.server";
import RowFiltersHelper from "../helpers/RowFiltersHelper";
import { SortedByDto } from "~/application/dtos/data/SortedByDto";
import { Prisma } from "@toolctrl/db/prisma-browser";
import DateUtils from "../shared/DateUtils";
import { DefaultLogActions } from "~/application/dtos/shared/DefaultLogActions";
import { cachified, clearCacheKey } from "../cache.server";
import { createScopedLogger } from "../logger.server";
import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher";
import { PLUGIN_HOOK_USER_CREATED } from "@toolctrl/plugin-runtime";
const usersLogger = createScopedLogger("db.users");
export type UserSimple = {
id: string;
email: string;
firstName: string;
lastName: string;
// avatar: string | null;
phone: string | null;
locale: string | null;
createdAt: Date;
};
export type UserWithNames = {
id: string;
email: string;
firstName: string;
lastName: string;
};
export type UserWithoutPassword = {
id: string;
email: string;
firstName: string;
lastName: string;
avatar: string | null;
phone: string | null;
admin?: AdminUser | null;
defaultTenantId: string | null;
createdAt: Date;
locale: string | null;
lightOrDarkMode?: string | null;
};
export type UserWithRoles = UserSimple & {
admin?: AdminUser | null;
roles: (UserRole & { role: Role })[];
};
export type UserTotpStatus = {
enabled: boolean;
pending: boolean;
enabledAt: Date | null;
pendingCreatedAt: Date | null;
};
export type UserTotpPendingEnrollment = {
totpPendingSecretEncrypted: string | null;
totpPendingCreatedAt: Date | null;
};
export type UserMfaRecoveryCodeStatus = {
configured: boolean;
remainingCount: number;
generatedAt: Date | null;
lastUsedAt: Date | null;
};
export type UserMfaRecoveryCodeConsumeResult = {
consumed: boolean;
remainingCount: number;
usedAt: Date | null;
};
export type UserWithDetails = UserWithoutPassword & {
admin: AdminUser | null;
tenants: (TenantUser & { tenant: Tenant })[];
roles: (UserRole & { role: Role })[];
};
export const includeCreatedBy = {
byUser: {
select: { id: true, email: true, firstName: true, lastName: true },
},
byApiKey: {
select: { id: true, alias: true },
},
byEmail: {
select: { id: true, subject: true },
},
byEventWebhookAttempt: {
select: { id: true, endpoint: true, message: true },
},
};
export async function adminGetAllTenantUsers(tenantId: string): Promise<UserWithDetails[]> {
return db.user.findMany({
where: {
tenants: {
some: {
tenantId,
},
},
},
include: {
admin: true,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
});
}
export async function getTenantScopedUsersWithDetails(tenantId: string): Promise<UserWithDetails[]> {
return db.user.findMany({
where: {
tenants: {
some: {
tenantId,
},
},
},
include: {
admin: true,
tenants: {
where: {
tenantId,
},
include: {
tenant: true,
},
},
roles: {
where: {
tenantId,
},
include: {
role: true,
},
},
},
});
}
export async function adminGetAllUsers(
filters?: FiltersDto,
pagination?: { page: number; pageSize: number; sortedBy?: SortedByDto[] }
): Promise<{ items: UserWithDetails[]; pagination: PaginationDto }> {
let where: Prisma.UserWhereInput = RowFiltersHelper.getFiltersCondition(filters);
const tenantId = filters?.properties.find((f) => f.name === "tenantId")?.value ?? filters?.query ?? "";
if (tenantId) {
where = {
OR: [where, { tenants: { some: { tenantId } } }],
};
}
// lastLogin manual filter
const lastLoginFilter = filters?.properties.find((f) => f.name === "lastLogin");
if (lastLoginFilter?.value) {
const gte = DateUtils.gteFromFilter(lastLoginFilter.value);
const logs = (
await db.log.findMany({
where: { userId: { not: null }, createdAt: { gte }, action: DefaultLogActions.Login },
select: { userId: true },
})
).map((l) => l.userId);
const usersIn: string[] = logs.filter((f) => f !== null).map((m) => m as string);
where = {
AND: [where, { id: { in: usersIn } }],
};
}
const isAdminFilter = filters?.properties.find((f) => f.name === "isAdmin");
if (isAdminFilter?.value) {
if (isAdminFilter.value === "true") {
where = {
AND: [where, { admin: { isNot: null } }],
};
} else {
where = {
AND: [where, { admin: null }],
};
}
}
let orderBy: Prisma.UserOrderByWithRelationInput[] = [{ createdAt: "desc" }];
if (pagination?.sortedBy?.length) {
pagination.sortedBy = pagination.sortedBy.filter((s) => ["email", "firstName", "lastName", "createdAt"].includes(s.name));
orderBy = pagination.sortedBy.map((s) => {
return { [s.name]: s.direction };
});
}
const [items, totalItems] = await Promise.all([
db.user.findMany({
skip: pagination ? pagination?.pageSize * (pagination?.page - 1) : undefined,
take: pagination ? pagination?.pageSize : undefined,
where,
include: {
admin: true,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
orderBy,
}),
db.user.count({
where,
}),
]);
return {
items,
pagination: {
page: pagination?.page ?? 1,
pageSize: pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE,
totalItems,
totalPages: Math.ceil(totalItems / (pagination?.pageSize ?? Constants.DEFAULT_PAGE_SIZE)),
},
};
}
export async function adminGetAllUsersNames(): Promise<UserWithNames[]> {
return db.user.findMany({
select: { id: true, email: true, firstName: true, lastName: true },
});
}
export async function adminGetAllUsersNamesInIds(ids: string[]): Promise<UserWithNames[]> {
return db.user.findMany({
select: { id: true, email: true, firstName: true, lastName: true },
where: {
id: { in: ids },
},
});
}
export async function getUsersById(ids: string[]): Promise<UserWithDetails[]> {
return db.user.findMany({
where: {
id: {
in: ids,
},
},
include: {
admin: true,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
});
}
export async function getAdminUsersInRoles(roles: string[]): Promise<UserWithDetails[]> {
return db.user.findMany({
where: { roles: { some: { role: { id: { in: roles } } } } },
include: {
admin: true,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
});
}
export async function getUsersByTenant(tenantId: string | null): Promise<UserWithDetails[]> {
let where = {};
if (tenantId) {
where = {
tenants: {
some: {
tenantId,
},
},
};
} else {
where = {
admin: {
isNot: null,
},
};
}
return db.user.findMany({
where,
include: {
admin: true,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
});
}
export async function getUser(userId?: string): Promise<UserWithoutPassword | null> {
if (!userId) {
return null;
}
try {
return await cachified({
key: `user:${userId}`,
ttl: 1000 * 60 * 60 * 24,
getFreshValue: async () =>
db.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
phone: true,
admin: true,
defaultTenantId: true,
createdAt: true,
locale: true,
lightOrDarkMode: true,
},
}),
});
} catch (error: any) {
usersLogger.warn({ err: error }, "Could not fetch user with mfa snapshot");
return null;
}
}
export async function setUserTotpPendingSecret(userId: string | undefined, encryptedSecret: string, pendingCreatedAt: Date = new Date()) {
if (!userId) {
return null;
}
return await db.user
.update({
where: { id: userId },
data: {
totpPendingSecretEncrypted: encryptedSecret,
totpPendingCreatedAt: pendingCreatedAt,
},
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}
export async function activateUserTotpPendingSecret(userId: string | undefined, encryptedSecret: string, enabledAt: Date = new Date()) {
if (!userId) {
return null;
}
return await db.user
.update({
where: { id: userId },
data: {
totpSecretEncrypted: encryptedSecret,
totpEnabledAt: enabledAt,
totpPendingSecretEncrypted: null,
totpPendingCreatedAt: null,
},
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}
export async function disableUserTotp(userId: string | undefined) {
if (!userId) {
return null;
}
return await db.user
.update({
where: { id: userId },
data: {
totpSecretEncrypted: null,
totpPendingSecretEncrypted: null,
totpEnabledAt: null,
totpPendingCreatedAt: null,
},
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}
export async function getUserTotpStatus(userId: string | undefined): Promise<UserTotpStatus | null> {
if (!userId) {
return null;
}
const status = await db.user.findUnique({
where: { id: userId },
select: {
totpSecretEncrypted: true,
totpPendingSecretEncrypted: true,
totpEnabledAt: true,
totpPendingCreatedAt: true,
},
});
if (!status) {
return null;
}
return {
enabled: Boolean(status.totpSecretEncrypted && status.totpEnabledAt),
pending: Boolean(status.totpPendingSecretEncrypted && status.totpPendingCreatedAt),
enabledAt: status.totpEnabledAt,
pendingCreatedAt: status.totpPendingCreatedAt,
};
}
/**
* D1 (2FA-Login-Gate) — liefert das VERSCHLUESSELTE TOTP-Secret eines Nutzers, sofern TOTP aktiv
* (enabled) ist, sonst null. `verifyTotpCode` erwartet genau dieses verschluesselte Secret und
* entschluesselt intern. Wird beim Login-Verify gebraucht, ohne den Klartext zu beruehren.
*/
export async function getActiveTotpSecretEncrypted(userId: string | undefined): Promise<string | null> {
if (!userId) {
return null;
}
const user = await db.user.findUnique({
where: { id: userId },
select: { totpSecretEncrypted: true, totpEnabledAt: true },
});
if (!user?.totpSecretEncrypted || !user.totpEnabledAt) {
return null;
}
return user.totpSecretEncrypted;
}
export async function getUserTotpPendingEnrollment(userId: string | undefined): Promise<UserTotpPendingEnrollment | null> {
if (!userId) {
return null;
}
return await db.user.findUnique({
where: { id: userId },
select: {
totpPendingSecretEncrypted: true,
totpPendingCreatedAt: true,
},
});
}
export async function setUserMfaRecoveryCodes(
userId: string | undefined,
entries: RecoveryCodeHashEntry[],
generatedAt: Date = new Date()
) {
if (!userId) {
return null;
}
return await db.user
.update({
where: { id: userId },
data: {
mfaRecoveryCodeHashes: entries,
mfaRecoveryCodesGeneratedAt: generatedAt,
mfaRecoveryCodeLastUsedAt: null,
},
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}
export async function getUserMfaRecoveryCodeStatus(userId: string | undefined): Promise<UserMfaRecoveryCodeStatus | null> {
if (!userId) {
return null;
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
mfaRecoveryCodeHashes: true,
mfaRecoveryCodesGeneratedAt: true,
mfaRecoveryCodeLastUsedAt: true,
},
});
if (!user) {
return null;
}
const status = getRecoveryCodeStatus(user.mfaRecoveryCodeHashes as RecoveryCodeHashEntry[] | null, {
generatedAt: user.mfaRecoveryCodesGeneratedAt,
lastUsedAt: user.mfaRecoveryCodeLastUsedAt,
});
return {
configured: status.configured,
remainingCount: status.remainingCount,
generatedAt: status.generatedAt,
lastUsedAt: status.lastUsedAt,
};
}
export async function consumeUserMfaRecoveryCode(
userId: string | undefined,
code: string,
options: { now?: Date } = {}
): Promise<UserMfaRecoveryCodeConsumeResult | null> {
if (!userId) {
return null;
}
const user = await db.user.findUnique({
where: { id: userId },
select: {
mfaRecoveryCodeHashes: true,
mfaRecoveryCodesGeneratedAt: true,
mfaRecoveryCodeLastUsedAt: true,
},
});
const entries = user?.mfaRecoveryCodeHashes as RecoveryCodeHashEntry[] | null | undefined;
if (!user || !entries?.length) {
return { consumed: false, remainingCount: 0, usedAt: null };
}
const result = consumeRecoveryCode(entries, code, { now: options.now });
if (!result.matched || !result.usedAt) {
const status = getRecoveryCodeStatus(entries, {
generatedAt: user.mfaRecoveryCodesGeneratedAt,
lastUsedAt: user.mfaRecoveryCodeLastUsedAt,
});
return { consumed: false, remainingCount: status.remainingCount, usedAt: null };
}
const updateResult = await db.user.updateMany({
where: { id: userId, mfaRecoveryCodeHashes: { equals: entries as Prisma.InputJsonValue } },
data: {
mfaRecoveryCodeHashes: result.entries,
mfaRecoveryCodeLastUsedAt: result.usedAt,
},
});
if (updateResult.count !== 1) {
return { consumed: false, remainingCount: getRecoveryCodeStatus(entries).remainingCount, usedAt: null };
}
clearCacheKey(`user:${userId}`);
const status = getRecoveryCodeStatus(result.entries, {
generatedAt: user.mfaRecoveryCodesGeneratedAt,
lastUsedAt: result.usedAt,
});
return { consumed: true, remainingCount: status.remainingCount, usedAt: result.usedAt };
}
export async function getUserByEmail(email?: string) {
if (!email) {
return null;
}
return await db.user.findUnique({
where: { email },
include: {
tenants: true,
admin: true,
},
});
}
export async function getUserByEmailWithDetails(email: string): Promise<UserWithDetails | null> {
return await db.user.findUnique({
where: { email },
include: {
admin: true,
tenants: { include: { tenant: true } },
roles: { include: { role: true } },
},
});
}
export async function register(data: {
email: string;
password: string;
firstName: string;
lastName: string;
active?: boolean;
avatarURL?: string;
locale?: string;
defaultTenantId?: string | null;
request: Request;
}) {
const { email, password, firstName, lastName, active, avatarURL, locale, defaultTenantId } = data;
const passwordHash = await hashPassword(password);
const user = await db.user.create({
data: {
email,
passwordHash,
firstName,
lastName,
avatar: avatarURL ?? "",
phone: "",
active,
locale,
defaultTenantId,
},
});
// Phase 06 #22 — emit plugin hook `user.created` so installed plugins
// can react (e.g. provisioning external SSO accounts, notifications).
// Fire-and-forget: failures never propagate into user registration.
await emitPluginHookFireAndForget(
PLUGIN_HOOK_USER_CREATED,
{
userId: user.id,
tenantId: defaultTenantId ?? null,
email: user.email,
locale: user.locale ?? null,
createdAt: user.createdAt.toISOString(),
},
{ tenantId: defaultTenantId ?? null, dispatchKey: `user:${user.id}:created` }
);
return { id: user.id, email, defaultTenantId: user.defaultTenantId, locale };
}
export async function updateUserProfile(
data: { firstName?: string; lastName?: string; avatar?: string; locale?: string; lightOrDarkMode?: string },
userId?: string
) {
if (!userId) {
return null;
}
return await db.user
.update({
where: { id: userId },
data,
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}
export async function updateUserVerifyToken(data: { verifyToken: string; verifyTokenCreatedAt?: Date | null }, userId?: string) {
if (!userId) {
return null;
}
return await db.user
.update({
where: { id: userId },
data,
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}
export async function updateUserPassword(data: { passwordHash: string }, userId?: string) {
if (!userId) {
return null;
}
return await db.user
.update({
where: { id: userId },
data: {
...data,
verifyToken: "",
verifyTokenCreatedAt: null,
},
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}
export async function updateUserDefaultTenantId(data: { defaultTenantId: string }, userId: string) {
if (!userId) {
return null;
}
return await db.user
.update({
where: { id: userId },
data,
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}
export async function deleteUser(userId: string) {
return await db.user
.delete({
where: { id: userId },
})
.then((item) => {
clearCacheKey(`user:${userId}`);
return item;
});
}