984 lines
28 KiB
TypeScript
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;
|
|
}
|