720 lines
18 KiB
TypeScript
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;
|
|
});
|
|
}
|