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

984 lines
28 KiB
TypeScript

import { Role, Tenant, TenantUser, User, UserRole } from "@hivegrid/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 "@hivegrid/db/prisma-browser";
import DateUtils from "../shared/DateUtils";
import { DefaultLogActions } from "~/application/dtos/shared/DefaultLogActions";
import { createScopedLogger } from "../logger.server";
import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher";
import { PLUGIN_HOOK_USER_CREATED } from "@hivegrid/plugin-runtime";
import { invalidateUserSnapshot, loadCachedUserSnapshot } from "./user-snapshot-cache.server";
import { randomUUID } from "node:crypto";
import { getRlsExecutionContext, runWithIsolatedUserRlsContext, runWithRlsContext } from "@hivegrid/db/rls-context.server";
import { resolveUserEmailRlsIdentity } from "@hivegrid/db/rls-resolvers.server";
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;
/** Migration-only projection. Productive admin auth never follows a User relation. */
admin?: { userId: string } | null;
defaultTenantId: string | null;
createdAt: Date;
locale: string | null;
lightOrDarkMode?: string | null;
};
export type UserWithRoles = UserSimple & {
admin?: { userId: string } | 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: { userId: string } | null;
tenants: (TenantUser & { tenant: Tenant })[];
roles: (UserRole & { role: Role })[];
};
export type RegisterTransactionMutation = (transaction: Prisma.TransactionClient, user: User) => Promise<void>;
type UserCreatedHookInput = Pick<User, "id" | "email" | "locale" | "createdAt"> & {
defaultTenantId: string | null;
};
const ADMIN_USER_HYDRATION_BATCH_SIZE = 10;
const adminUserRootSelect = {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
phone: true,
defaultTenantId: true,
createdAt: true,
locale: true,
lightOrDarkMode: true,
} satisfies Prisma.UserSelect;
type AdminUserRootRow = Prisma.UserGetPayload<{ select: typeof adminUserRootSelect }>;
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[]> {
const users = await db.user.findMany({
where: {
tenants: {
some: {
tenantId,
},
},
},
select: {
...adminUserRootSelect,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
});
return hydrateAdminUserDetails(users);
}
export async function getTenantScopedUsersWithDetails(tenantId: string): Promise<UserWithDetails[]> {
const users = await db.user.findMany({
where: {
tenants: {
some: {
tenantId,
},
},
},
select: {
...adminUserRootSelect,
tenants: {
where: {
tenantId,
},
include: {
tenant: true,
},
},
roles: {
where: {
tenantId,
},
include: {
role: true,
},
},
},
});
return hydrateAdminUserDetails(users);
}
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 ?? "";
if (tenantId) {
const tenantUserIds = await runWithRlsContext({ tenantId }, () =>
db.tenantUser.findMany({
where: { tenantId },
select: { userId: true },
})
);
where = {
AND: [where, { id: { in: tenantUserIds.map((membership) => membership.userId) } }],
};
}
// 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") {
const adminIds = await db.adminUser.findMany({ select: { id: true } });
where = { AND: [where, { id: { in: adminIds.map((admin) => admin.id) } }] };
} else {
const adminIds = await db.adminUser.findMany({ select: { id: true } });
where = { AND: [where, { id: { notIn: adminIds.map((admin) => admin.id) } }] };
}
}
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 [userRoots, totalItems] = await Promise.all([
db.user.findMany({
skip: pagination ? pagination?.pageSize * (pagination?.page - 1) : undefined,
take: pagination ? pagination?.pageSize : undefined,
where,
select: adminUserRootSelect,
orderBy,
}),
db.user.count({
where,
}),
]);
const items = await hydrateAdminUserDetails(userRoots);
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[]> {
const users = await db.user.findMany({
where: {
id: {
in: ids,
},
},
select: {
...adminUserRootSelect,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
});
return hydrateAdminUserDetails(users);
}
export async function getAdminUsersInRoles(roles: string[]): Promise<UserWithDetails[]> {
const users = await db.user.findMany({
where: { roles: { some: { role: { id: { in: roles } } } } },
select: {
...adminUserRootSelect,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
});
return hydrateAdminUserDetails(users);
}
export async function getUsersByTenant(tenantId: string | null): Promise<UserWithDetails[]> {
let where = {};
if (tenantId) {
where = {
tenants: {
some: {
tenantId,
},
},
};
} else {
const adminIds = await db.adminUser.findMany({ select: { id: true } });
where = { id: { in: adminIds.map((admin) => admin.id) } };
}
const users = await db.user.findMany({
where,
select: {
...adminUserRootSelect,
tenants: {
include: {
tenant: true,
},
},
roles: {
include: {
role: true,
},
},
},
});
return hydrateAdminUserDetails(users);
}
export async function getUser(userId?: string): Promise<UserWithoutPassword | null> {
if (!userId) {
return null;
}
try {
return await withUserRlsContext(userId, () =>
loadCachedUserSnapshot(userId, async () =>
db.user
.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
avatar: true,
phone: true,
defaultTenantId: true,
createdAt: true,
locale: true,
lightOrDarkMode: true,
},
})
.then(async (user) => {
if (!user) return null;
const admin = await db.adminUser.findUnique({ where: { id: user.id }, select: { id: true } });
return { ...user, admin: admin ? { userId: admin.id } : null };
})
)
);
} 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 withUserRlsContext(userId, () =>
db.user
.update({
where: { id: userId },
data: {
totpPendingSecretEncrypted: encryptedSecret,
totpPendingCreatedAt: pendingCreatedAt,
},
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
export async function clearUserTotpPendingSecret(userId: string | undefined, expected: UserTotpPendingEnrollment) {
if (!userId) {
return null;
}
return await withUserRlsContext(userId, () =>
db.user
.updateMany({
where: {
id: userId,
totpPendingSecretEncrypted: expected.totpPendingSecretEncrypted,
totpPendingCreatedAt: expected.totpPendingCreatedAt,
},
data: {
totpPendingSecretEncrypted: null,
totpPendingCreatedAt: null,
},
})
.then(async (result) => {
if (result.count > 0) {
await invalidateUserSnapshot(userId);
}
return result;
})
);
}
export async function activateUserTotpPendingSecret(userId: string | undefined, encryptedSecret: string, enabledAt: Date = new Date()) {
if (!userId) {
return null;
}
return await withUserRlsContext(userId, () =>
db.user
.update({
where: { id: userId },
data: {
totpSecretEncrypted: encryptedSecret,
totpEnabledAt: enabledAt,
totpPendingSecretEncrypted: null,
totpPendingCreatedAt: null,
},
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
export async function disableUserTotp(userId: string | undefined) {
if (!userId) {
return null;
}
return await withUserRlsContext(userId, () =>
db.user
.update({
where: { id: userId },
data: {
totpSecretEncrypted: null,
totpPendingSecretEncrypted: null,
totpEnabledAt: null,
totpPendingCreatedAt: null,
},
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
/**
* Batch variant of `getUserTotpStatus` for list views (e.g. the members table):
* returns the subset of the given user ids that have TOTP fully enabled
* (secret + enabledAt set). Uses a single query instead of one findUnique per
* user, avoiding an N+1 when rendering member lists. The enabled predicate
* matches `getUserTotpStatus().enabled` exactly.
*/
export async function getEnabledTotpUserIds(userIds: string[]): Promise<string[]> {
if (userIds.length === 0) {
return [];
}
const rows = await db.user.findMany({
where: {
id: { in: userIds },
totpSecretEncrypted: { not: null },
totpEnabledAt: { not: null },
},
select: { id: true },
});
return rows.map((row) => row.id);
}
export async function getUserTotpStatus(userId: string | undefined): Promise<UserTotpStatus | null> {
if (!userId) {
return null;
}
const status = await withUserRlsContext(userId, () =>
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 withUserRlsContext(userId, () =>
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 withUserRlsContext(userId, () =>
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 withUserRlsContext(userId, () =>
db.user
.update({
where: { id: userId },
data: {
mfaRecoveryCodeHashes: entries,
mfaRecoveryCodesGeneratedAt: generatedAt,
mfaRecoveryCodeLastUsedAt: null,
},
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
export async function getUserMfaRecoveryCodeStatus(userId: string | undefined): Promise<UserMfaRecoveryCodeStatus | null> {
if (!userId) {
return null;
}
const user = await withUserRlsContext(userId, () =>
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 withUserRlsContext(userId, () =>
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 withUserRlsContext(userId, () =>
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 };
}
await invalidateUserSnapshot(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 withResolvedUserEmail(email, (resolvedUserId) =>
db.user
.findUnique({
where: { id: resolvedUserId },
include: {
tenants: true,
},
})
.then(async (user) => {
if (!user) return null;
const admin = await db.adminUser.findUnique({ where: { id: user.id }, select: { id: true } });
return { ...user, admin: admin ? { userId: admin.id } : null };
})
);
}
export async function getUserByEmailWithDetails(email: string): Promise<UserWithDetails | null> {
return withResolvedUserEmail(email, async (resolvedUserId) => {
const user = await db.user.findUnique({
where: { id: resolvedUserId },
select: adminUserRootSelect,
});
if (!user) return null;
return (await hydrateAdminUserDetails([user]))[0] ?? null;
});
}
export async function register(data: {
email: string;
password: string;
firstName: string;
lastName: string;
active?: boolean;
avatarURL?: string;
locale?: string;
defaultTenantId?: string | null;
request: Request;
transactionalMutation?: RegisterTransactionMutation;
emitCreatedHook?: boolean;
}) {
const { password, firstName, lastName, active, avatarURL, locale, defaultTenantId, transactionalMutation, emitCreatedHook = true } = data;
const email = data.email.trim().toLowerCase();
const passwordHash = await hashPassword(password);
const userId = randomUUID();
const createUser = () =>
db.$transaction(async (transaction) => {
const user = await transaction.user.create({
data: {
id: userId,
email,
passwordHash,
firstName,
lastName,
avatar: avatarURL ?? "",
phone: "",
active,
locale,
defaultTenantId,
},
});
await transactionalMutation?.(transaction, user);
return user;
});
// An authenticated operator must remain the database actor while creating a
// different user. Anonymous registration instead binds the new user's id so
// PostgreSQL can prove the self-owned insert.
const user = getRlsExecutionContext()?.userId ? await createUser() : await runWithRlsContext({ userId }, createUser);
// 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.
if (emitCreatedHook) {
await emitUserCreatedHook(user);
}
return { id: user.id, email: user.email, defaultTenantId: user.defaultTenantId, locale, createdAt: user.createdAt };
}
/**
* Emits the user lifecycle hook after the transaction that created the user
* committed. Atomic tenant bootstrap uses this helper instead of emitting a
* hook from inside its interactive transaction.
*/
export async function emitUserCreatedHook(user: UserCreatedHookInput): Promise<void> {
const emit = () =>
emitPluginHookFireAndForget(
PLUGIN_HOOK_USER_CREATED,
{
userId: user.id,
tenantId: user.defaultTenantId,
email: user.email,
locale: user.locale ?? null,
createdAt: user.createdAt.toISOString(),
},
{ tenantId: user.defaultTenantId, dispatchKey: `user:${user.id}:created` }
);
const current = getRlsExecutionContext();
if (!current?.userId) {
await runWithRlsContext({ userId: user.id, tenantId: user.defaultTenantId ?? undefined }, emit);
return;
}
const emitInTargetTenant = () => (user.defaultTenantId ? runWithRlsContext({ tenantId: user.defaultTenantId }, emit) : emit());
if (current.transaction || (current.tenantId && current.tenantId !== user.defaultTenantId)) {
await runWithIsolatedUserRlsContext(current.userId, emitInTargetTenant);
return;
}
await emitInTargetTenant();
}
async function hydrateAdminUserDetails(userRoots: readonly AdminUserRootRow[]): Promise<UserWithDetails[]> {
if (userRoots.length === 0) return [];
const userIds = userRoots.map((user) => user.id);
const currentTenantId = getRlsExecutionContext()?.tenantId;
const tenantIds = currentTenantId
? [currentTenantId]
: (
await db.tenant.findMany({
select: { id: true },
orderBy: { id: "asc" },
})
).map((tenant) => tenant.id);
const globalRoles = await db.userRole.findMany({
where: { userId: { in: userIds }, tenantId: null },
include: { role: true },
});
const adminUsers = await db.adminUser.findMany({
where: { id: { in: userIds } },
select: { id: true },
});
const adminIds = new Set(adminUsers.map((admin) => admin.id));
const tenantMemberships: (TenantUser & { tenant: Tenant })[] = [];
const tenantRoles: (UserRole & { role: Role })[] = [];
for (let index = 0; index < tenantIds.length; index += ADMIN_USER_HYDRATION_BATCH_SIZE) {
const batch = tenantIds.slice(index, index + ADMIN_USER_HYDRATION_BATCH_SIZE);
const rows = await Promise.all(
batch.map((tenantId) =>
runWithRlsContext({ tenantId }, () =>
db.$transaction(async (tx) => {
const memberships = await tx.tenantUser.findMany({
where: { tenantId, userId: { in: userIds } },
include: { tenant: true },
});
const roles = await tx.userRole.findMany({
where: { tenantId, userId: { in: userIds } },
include: { role: true },
});
return { memberships, roles };
})
)
)
);
rows.forEach(({ memberships, roles }) => {
tenantMemberships.push(...memberships);
tenantRoles.push(...roles);
});
}
return userRoots.map((user) => ({
...user,
admin: adminIds.has(user.id) ? { userId: user.id } : null,
tenants: tenantMemberships.filter((membership) => membership.userId === user.id),
roles: [...globalRoles, ...tenantRoles].filter((assignment) => assignment.userId === user.id),
}));
}
export async function updateUserProfile(
data: { firstName?: string; lastName?: string; avatar?: string; locale?: string; lightOrDarkMode?: string },
userId?: string
) {
if (!userId) {
return null;
}
return await withUserRlsContext(userId, () =>
db.user
.update({
where: { id: userId },
data,
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
export async function updateUserVerifyToken(data: { verifyToken: string; verifyTokenCreatedAt?: Date | null }, userId?: string) {
if (!userId) {
return null;
}
return await withUserRlsContext(userId, () =>
db.user
.update({
where: { id: userId },
data,
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
export async function updateUserPassword(data: { passwordHash: string }, userId?: string) {
if (!userId) {
return null;
}
return await withUserRlsContext(userId, () =>
db.user
.update({
where: { id: userId },
data: {
...data,
verifyToken: "",
verifyTokenCreatedAt: null,
},
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
export async function consumePasswordResetTokenAndUpdatePassword({
userId,
passwordHash,
expectedVerifyToken,
expectedVerifyTokenCreatedAt,
minimumVerifyTokenCreatedAt,
}: {
userId: string;
passwordHash: string;
expectedVerifyToken: string;
expectedVerifyTokenCreatedAt: Date;
minimumVerifyTokenCreatedAt: Date;
}): Promise<boolean> {
const result = await withUserRlsContext(userId, () =>
db.user.updateMany({
where: {
id: userId,
verifyToken: expectedVerifyToken,
verifyTokenCreatedAt: {
equals: expectedVerifyTokenCreatedAt,
gt: minimumVerifyTokenCreatedAt,
},
},
data: {
passwordHash,
verifyToken: "",
verifyTokenCreatedAt: null,
},
})
);
if (result.count !== 1) {
return false;
}
await invalidateUserSnapshot(userId);
return true;
}
export async function updateUserDefaultTenantId(data: { defaultTenantId: string }, userId: string) {
if (!userId) {
return null;
}
return await withUserRlsContext(userId, () =>
db.user
.update({
where: { id: userId },
data,
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
export async function deleteUser(userId: string) {
return await withUserRlsContext(userId, () =>
db.user
.delete({
where: { id: userId },
})
.then(async (item) => {
await invalidateUserSnapshot(userId);
return item;
})
);
}
function withUserRlsContext<T>(userId: string, operation: () => T): T {
if (getRlsExecutionContext()?.userId) {
return operation();
}
return runWithRlsContext({ userId }, operation);
}
async function withResolvedUserEmail<T>(email: string, operation: (resolvedUserId: string) => Promise<T>): Promise<T | null> {
const identity = await resolveUserEmailRlsIdentity(email);
if (!identity) {
return null;
}
const result = getRlsExecutionContext()?.userId
? await operation(identity.userId)
: await runWithRlsContext({ userId: identity.userId }, () => operation(identity.userId));
if (result === null) {
usersLogger.warn(
{ op: "resolveUserEmail.scopedRowMissing", resolverMatched: true },
"The pre-auth resolver matched an identity, but its RLS-scoped user row was not visible."
);
}
return result;
}