mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-29 09:12:56 +00:00
Merge branch 'dev' into feat/paginate-user-roles-table
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, orgs, UserOrg } from "@server/db";
|
||||
import { roles, userInvites, userOrgs, users } from "@server/db";
|
||||
import { eq, and, inArray, ne } from "drizzle-orm";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -141,17 +141,34 @@ export async function acceptInvite(
|
||||
);
|
||||
}
|
||||
|
||||
let roleId: number;
|
||||
// get the role to make sure it exists
|
||||
const existingRole = await db
|
||||
const inviteRoleRows = await db
|
||||
.select({ roleId: userInviteRoles.roleId })
|
||||
.from(userInviteRoles)
|
||||
.where(eq(userInviteRoles.inviteId, inviteId));
|
||||
|
||||
const inviteRoleIds = [
|
||||
...new Set(inviteRoleRows.map((r) => r.roleId))
|
||||
];
|
||||
if (inviteRoleIds.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"This invitation has no roles. Please contact an admin."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, existingInvite.roleId))
|
||||
.limit(1);
|
||||
if (existingRole.length) {
|
||||
roleId = existingRole[0].roleId;
|
||||
} else {
|
||||
// TODO: use the default role on the org instead of failing
|
||||
.where(
|
||||
and(
|
||||
eq(roles.orgId, existingInvite.orgId),
|
||||
inArray(roles.roleId, inviteRoleIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (existingRoles.length !== inviteRoleIds.length) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -165,9 +182,9 @@ export async function acceptInvite(
|
||||
org,
|
||||
{
|
||||
userId: existingUser[0].userId,
|
||||
orgId: existingInvite.orgId,
|
||||
roleId: existingInvite.roleId
|
||||
orgId: existingInvite.orgId
|
||||
},
|
||||
inviteRoleIds,
|
||||
trx
|
||||
);
|
||||
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { clients, db, UserOrg } from "@server/db";
|
||||
import { userOrgs, roles } from "@server/db";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { clients, db } from "@server/db";
|
||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||
|
||||
const addUserRoleParamsSchema = z.strictObject({
|
||||
userId: z.string(),
|
||||
roleId: z.string().transform(stoi).pipe(z.number())
|
||||
/** Legacy path param order: /role/:roleId/add/:userId */
|
||||
const addUserRoleLegacyParamsSchema = z.strictObject({
|
||||
roleId: z.string().transform(stoi).pipe(z.number()),
|
||||
userId: z.string()
|
||||
});
|
||||
|
||||
export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/role/{roleId}/add/{userId}",
|
||||
description: "Add a role to a user.",
|
||||
description:
|
||||
"Legacy: set exactly one role for the user (replaces any other roles the user has in the org).",
|
||||
tags: [OpenAPITags.Role, OpenAPITags.User],
|
||||
request: {
|
||||
params: addUserRoleParamsSchema
|
||||
params: addUserRoleLegacyParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function addUserRole(
|
||||
export async function addUserRoleLegacy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = addUserRoleParamsSchema.safeParse(req.params);
|
||||
const parsedParams = addUserRoleLegacyParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -57,7 +59,6 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
// get the role
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -70,7 +71,7 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await db
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
@@ -78,7 +79,7 @@ export async function addUserRole(
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
if (!existingUser) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -87,7 +88,7 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser[0].isOwner) {
|
||||
if (existingUser.isOwner) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
@@ -96,13 +97,13 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
const roleExists = await db
|
||||
const [roleInOrg] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (roleExists.length === 0) {
|
||||
if (!roleInOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -111,20 +112,22 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
let newUserRole: UserOrg | null = null;
|
||||
await db.transaction(async (trx) => {
|
||||
[newUserRole] = await trx
|
||||
.update(userOrgs)
|
||||
.set({ roleId })
|
||||
await trx
|
||||
.delete(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, role.orgId)
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, role.orgId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
);
|
||||
|
||||
await trx.insert(userOrgRoles).values({
|
||||
userId,
|
||||
orgId: role.orgId,
|
||||
roleId
|
||||
});
|
||||
|
||||
// get the client associated with this user in this org
|
||||
const orgClients = await trx
|
||||
.select()
|
||||
.from(clients)
|
||||
@@ -133,17 +136,15 @@ export async function addUserRole(
|
||||
eq(clients.userId, userId),
|
||||
eq(clients.orgId, role.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
);
|
||||
|
||||
for (const orgClient of orgClients) {
|
||||
// we just changed the user's role, so we need to rebuild client associations and what they have access to
|
||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: newUserRole,
|
||||
data: { ...existingUser, roleId },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Role added to user successfully",
|
||||
@@ -6,8 +6,8 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { db, orgs, UserOrg } from "@server/db";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
@@ -15,21 +15,43 @@ import { FeatureId } from "@server/lib/billing";
|
||||
import { build } from "@server/build";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { assignUserToOrg } from "@server/lib/userOrg";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
email: z.string().email().toLowerCase().optional(),
|
||||
username: z.string().nonempty().toLowerCase(),
|
||||
name: z.string().optional(),
|
||||
type: z.enum(["internal", "oidc"]).optional(),
|
||||
idpId: z.number().optional(),
|
||||
roleId: z.number()
|
||||
});
|
||||
const bodySchema = z
|
||||
.strictObject({
|
||||
email: z.string().email().toLowerCase().optional(),
|
||||
username: z.string().nonempty().toLowerCase(),
|
||||
name: z.string().optional(),
|
||||
type: z.enum(["internal", "oidc"]).optional(),
|
||||
idpId: z.number().optional(),
|
||||
roleIds: z.array(z.number().int().positive()).min(1).optional(),
|
||||
roleId: z.number().int().positive().optional()
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
{ message: "roleIds or roleId is required", path: ["roleIds"] }
|
||||
)
|
||||
.transform((data) => ({
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
idpId: data.idpId,
|
||||
roleIds: [
|
||||
...new Set(
|
||||
data.roleIds && data.roleIds.length > 0
|
||||
? data.roleIds
|
||||
: [data.roleId!]
|
||||
)
|
||||
]
|
||||
}));
|
||||
|
||||
export type CreateOrgUserResponse = {};
|
||||
|
||||
@@ -78,7 +100,8 @@ export async function createOrgUser(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { username, email, name, type, idpId, roleId } = parsedBody.data;
|
||||
const { username, email, name, type, idpId, roleIds: uniqueRoleIds } =
|
||||
parsedBody.data;
|
||||
|
||||
if (build == "saas") {
|
||||
const usage = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||
@@ -109,17 +132,6 @@ export async function createOrgUser(
|
||||
}
|
||||
}
|
||||
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId));
|
||||
|
||||
if (!role) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Role ID not found")
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "internal") {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -152,6 +164,38 @@ export async function createOrgUser(
|
||||
);
|
||||
}
|
||||
|
||||
const supportsMultiRole = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.FullRbac]
|
||||
);
|
||||
if (!supportsMultiRole && uniqueRoleIds.length > 1) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Multiple roles per user require a subscription or license that includes full RBAC."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgRoles = await db
|
||||
.select({ roleId: roles.roleId })
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
eq(roles.orgId, orgId),
|
||||
inArray(roles.roleId, uniqueRoleIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (orgRoles.length !== uniqueRoleIds.length) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid role ID or role does not belong to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
@@ -221,12 +265,16 @@ export async function createOrgUser(
|
||||
);
|
||||
}
|
||||
|
||||
await assignUserToOrg(org, {
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
}, trx);
|
||||
await assignUserToOrg(
|
||||
org,
|
||||
{
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
autoProvisioned: false,
|
||||
},
|
||||
uniqueRoleIds,
|
||||
trx
|
||||
);
|
||||
} else {
|
||||
userId = generateId(15);
|
||||
|
||||
@@ -244,12 +292,16 @@ export async function createOrgUser(
|
||||
})
|
||||
.returning();
|
||||
|
||||
await assignUserToOrg(org, {
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
roleId: role.roleId,
|
||||
autoProvisioned: false
|
||||
}, trx);
|
||||
await assignUserToOrg(
|
||||
org,
|
||||
{
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
autoProvisioned: false,
|
||||
},
|
||||
uniqueRoleIds,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, idp, idpOidcConfig } from "@server/db";
|
||||
import { roles, userOrgs, users } from "@server/db";
|
||||
import { roles, userOrgRoles, userOrgs, users } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -12,7 +12,7 @@ import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
export async function queryUser(orgId: string, userId: string) {
|
||||
const [user] = await db
|
||||
const [userRow] = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId,
|
||||
userId: users.userId,
|
||||
@@ -20,10 +20,7 @@ export async function queryUser(orgId: string, userId: string) {
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
isAdmin: roles.isAdmin,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
autoProvisioned: userOrgs.autoProvisioned,
|
||||
idpId: users.idpId,
|
||||
@@ -33,13 +30,40 @@ export async function queryUser(orgId: string, userId: string) {
|
||||
idpAutoProvision: idp.autoProvision
|
||||
})
|
||||
.from(userOrgs)
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
return user;
|
||||
|
||||
if (!userRow) return undefined;
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
roleId: userOrgRoles.roleId,
|
||||
roleName: roles.name,
|
||||
isAdmin: roles.isAdmin
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const isAdmin = roleRows.some((r) => r.isAdmin);
|
||||
|
||||
return {
|
||||
...userRow,
|
||||
isAdmin,
|
||||
roleIds: roleRows.map((r) => r.roleId),
|
||||
roles: roleRows.map((r) => ({
|
||||
roleId: r.roleId,
|
||||
name: r.roleName ?? ""
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export type GetOrgUserResponse = NonNullable<
|
||||
|
||||
@@ -20,7 +20,8 @@ async function queryUser(userId: string) {
|
||||
emailVerified: users.emailVerified,
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
idpId: users.idpId,
|
||||
locale: users.locale
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export * from "./getUser";
|
||||
export * from "./removeUserOrg";
|
||||
export * from "./listUsers";
|
||||
export * from "./addUserRole";
|
||||
export * from "./types";
|
||||
export * from "./addUserRoleLegacy";
|
||||
export * from "./inviteUser";
|
||||
export * from "./acceptInvite";
|
||||
export * from "./getOrgUser";
|
||||
@@ -16,4 +17,5 @@ export * from "./createOrgUser";
|
||||
export * from "./adminUpdateUser2FA";
|
||||
export * from "./adminGetUser";
|
||||
export * from "./updateOrgUser";
|
||||
export * from "./updateUserLocale";
|
||||
export * from "./myDevice";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { orgs, roles, userInvites, userOrgs, users } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -18,22 +18,44 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
|
||||
const inviteUserParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const inviteUserBodySchema = z.strictObject({
|
||||
email: z.email().toLowerCase(),
|
||||
roleId: z.number(),
|
||||
validHours: z.number().gt(0).lte(168),
|
||||
sendEmail: z.boolean().optional(),
|
||||
regenerate: z.boolean().optional()
|
||||
});
|
||||
const inviteUserBodySchema = z
|
||||
.strictObject({
|
||||
email: z.email().toLowerCase(),
|
||||
roleIds: z.array(z.number().int().positive()).min(1).optional(),
|
||||
roleId: z.number().int().positive().optional(),
|
||||
validHours: z.number().gt(0).lte(168),
|
||||
sendEmail: z.boolean().optional(),
|
||||
regenerate: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
{ message: "roleIds or roleId is required", path: ["roleIds"] }
|
||||
)
|
||||
.transform((data) => ({
|
||||
email: data.email,
|
||||
validHours: data.validHours,
|
||||
sendEmail: data.sendEmail,
|
||||
regenerate: data.regenerate,
|
||||
roleIds: [
|
||||
...new Set(
|
||||
data.roleIds && data.roleIds.length > 0
|
||||
? data.roleIds
|
||||
: [data.roleId!]
|
||||
)
|
||||
]
|
||||
}));
|
||||
|
||||
export type InviteUserBody = z.infer<typeof inviteUserBodySchema>;
|
||||
export type InviteUserBody = z.input<typeof inviteUserBodySchema>;
|
||||
|
||||
export type InviteUserResponse = {
|
||||
inviteLink: string;
|
||||
@@ -88,7 +110,7 @@ export async function inviteUser(
|
||||
const {
|
||||
email,
|
||||
validHours,
|
||||
roleId,
|
||||
roleIds: uniqueRoleIds,
|
||||
sendEmail: doEmail,
|
||||
regenerate
|
||||
} = parsedBody.data;
|
||||
@@ -105,14 +127,30 @@ export async function inviteUser(
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that the roleId belongs to the target organization
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
const supportsMultiRole = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.FullRbac]
|
||||
);
|
||||
if (!supportsMultiRole && uniqueRoleIds.length > 1) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Multiple roles per user require a subscription or license that includes full RBAC."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
const orgRoles = await db
|
||||
.select({ roleId: roles.roleId })
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
eq(roles.orgId, orgId),
|
||||
inArray(roles.roleId, uniqueRoleIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (orgRoles.length !== uniqueRoleIds.length) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -191,7 +229,8 @@ export async function inviteUser(
|
||||
}
|
||||
|
||||
if (existingInvite.length) {
|
||||
const attempts = (await cache.get<number>(email)) || 0;
|
||||
const attempts =
|
||||
(await cache.get<number>("regenerateInvite:" + email)) || 0;
|
||||
if (attempts >= 3) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -201,7 +240,7 @@ export async function inviteUser(
|
||||
);
|
||||
}
|
||||
|
||||
await cache.set(email, attempts + 1);
|
||||
await cache.set("regenerateInvite:" + email, attempts + 1, 3600);
|
||||
|
||||
const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId
|
||||
const token = generateRandomString(
|
||||
@@ -273,9 +312,11 @@ export async function inviteUser(
|
||||
orgId,
|
||||
email,
|
||||
expiresAt,
|
||||
tokenHash,
|
||||
roleId
|
||||
tokenHash
|
||||
});
|
||||
await trx.insert(userInviteRoles).values(
|
||||
uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
|
||||
);
|
||||
});
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { userInvites, roles } from "@server/db";
|
||||
import { userInvites, userInviteRoles, roles } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { sql, eq, and, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -29,24 +29,66 @@ const listInvitationsQuerySchema = z.strictObject({
|
||||
.pipe(z.int().nonnegative())
|
||||
});
|
||||
|
||||
async function queryInvitations(orgId: string, limit: number, offset: number) {
|
||||
return await db
|
||||
export type InvitationListRow = {
|
||||
inviteId: string;
|
||||
email: string;
|
||||
expiresAt: number;
|
||||
roles: { roleId: number; roleName: string | null }[];
|
||||
};
|
||||
|
||||
async function queryInvitations(
|
||||
orgId: string,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<InvitationListRow[]> {
|
||||
const inviteRows = await db
|
||||
.select({
|
||||
inviteId: userInvites.inviteId,
|
||||
email: userInvites.email,
|
||||
expiresAt: userInvites.expiresAt,
|
||||
roleId: userInvites.roleId,
|
||||
roleName: roles.name
|
||||
expiresAt: userInvites.expiresAt
|
||||
})
|
||||
.from(userInvites)
|
||||
.leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`)
|
||||
.where(sql`${userInvites.orgId} = ${orgId}`)
|
||||
.where(eq(userInvites.orgId, orgId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
if (inviteRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const inviteIds = inviteRows.map((r) => r.inviteId);
|
||||
const roleRows = await db
|
||||
.select({
|
||||
inviteId: userInviteRoles.inviteId,
|
||||
roleId: userInviteRoles.roleId,
|
||||
roleName: roles.name
|
||||
})
|
||||
.from(userInviteRoles)
|
||||
.innerJoin(roles, eq(userInviteRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(eq(roles.orgId, orgId), inArray(userInviteRoles.inviteId, inviteIds))
|
||||
);
|
||||
|
||||
const rolesByInvite = new Map<
|
||||
string,
|
||||
{ roleId: number; roleName: string | null }[]
|
||||
>();
|
||||
for (const row of roleRows) {
|
||||
const list = rolesByInvite.get(row.inviteId) ?? [];
|
||||
list.push({ roleId: row.roleId, roleName: row.roleName });
|
||||
rolesByInvite.set(row.inviteId, list);
|
||||
}
|
||||
|
||||
return inviteRows.map((inv) => ({
|
||||
inviteId: inv.inviteId,
|
||||
email: inv.email,
|
||||
expiresAt: inv.expiresAt,
|
||||
roles: rolesByInvite.get(inv.inviteId) ?? []
|
||||
}));
|
||||
}
|
||||
|
||||
export type ListInvitationsResponse = {
|
||||
invitations: NonNullable<Awaited<ReturnType<typeof queryInvitations>>>;
|
||||
invitations: InvitationListRow[];
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
|
||||
@@ -95,7 +137,7 @@ export async function listInvitations(
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userInvites)
|
||||
.where(sql`${userInvites.orgId} = ${orgId}`);
|
||||
.where(eq(userInvites.orgId, orgId));
|
||||
|
||||
return response<ListInvitationsResponse>(res, {
|
||||
data: {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, idpOidcConfig } from "@server/db";
|
||||
import { idp, roles, userOrgs, users } from "@server/db";
|
||||
import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, asc, desc, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
const listUsersParamsSchema = z.strictObject({
|
||||
@@ -75,8 +74,6 @@ function queryUsersBase() {
|
||||
username: users.username,
|
||||
name: users.name,
|
||||
type: users.type,
|
||||
roleId: userOrgs.roleId,
|
||||
roleName: roles.name,
|
||||
isOwner: userOrgs.isOwner,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
@@ -86,13 +83,19 @@ function queryUsersBase() {
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId));
|
||||
}
|
||||
|
||||
export type ListUsersResponse = PaginatedResponse<{
|
||||
users: NonNullable<Awaited<ReturnType<typeof queryUsersBase>>>;
|
||||
users: Array<
|
||||
NonNullable<Awaited<ReturnType<typeof queryUsersBase>>>[number] & {
|
||||
roles: Array<{
|
||||
roleId: number;
|
||||
roleName: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
@@ -175,16 +178,55 @@ export async function listUsers(
|
||||
: asc(users.name)
|
||||
);
|
||||
|
||||
const [count, usersWithRoles] = await Promise.all([
|
||||
const [total, usersWithoutRoles] = await Promise.all([
|
||||
countQuery,
|
||||
userListQuery
|
||||
]);
|
||||
|
||||
const userIds = usersWithoutRoles.map((r) => r.id);
|
||||
const roleRows =
|
||||
userIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
userId: userOrgRoles.userId,
|
||||
roleId: userOrgRoles.roleId,
|
||||
roleName: roles.name
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.orgId, orgId),
|
||||
inArray(userOrgRoles.userId, userIds)
|
||||
)
|
||||
);
|
||||
|
||||
const rolesByUser = new Map<
|
||||
string,
|
||||
{ roleId: number; roleName: string }[]
|
||||
>();
|
||||
for (const r of roleRows) {
|
||||
const list = rolesByUser.get(r.userId) ?? [];
|
||||
list.push({ roleId: r.roleId, roleName: r.roleName ?? "" });
|
||||
rolesByUser.set(r.userId, list);
|
||||
}
|
||||
|
||||
const usersWithRoles: ListUsersResponse["users"] = [];
|
||||
|
||||
for (const user of usersWithoutRoles) {
|
||||
const userRoles = rolesByUser.get(user.id) ?? [];
|
||||
usersWithRoles.push({
|
||||
...user,
|
||||
roles: userRoles
|
||||
});
|
||||
}
|
||||
|
||||
return response<ListUsersResponse>(res, {
|
||||
data: {
|
||||
users: usersWithRoles,
|
||||
pagination: {
|
||||
total: count,
|
||||
total,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, Olm, olms, orgs, userOrgs } from "@server/db";
|
||||
import { db, Olm, olms, orgs, userOrgRoles, userOrgs } from "@server/db";
|
||||
import { idp, users } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -63,7 +63,8 @@ export async function myDevice(
|
||||
emailVerified: users.emailVerified,
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId
|
||||
idpId: users.idpId,
|
||||
locale: users.locale
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
@@ -84,16 +85,31 @@ export async function myDevice(
|
||||
.from(olms)
|
||||
.where(and(eq(olms.userId, userId), eq(olms.olmId, olmId)));
|
||||
|
||||
const userOrganizations = await db
|
||||
const userOrgRows = await db
|
||||
.select({
|
||||
orgId: userOrgs.orgId,
|
||||
orgName: orgs.name,
|
||||
roleId: userOrgs.roleId
|
||||
orgName: orgs.name
|
||||
})
|
||||
.from(userOrgs)
|
||||
.where(eq(userOrgs.userId, userId))
|
||||
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId));
|
||||
|
||||
const roleRows = await db
|
||||
.select({
|
||||
orgId: userOrgRoles.orgId,
|
||||
roleId: userOrgRoles.roleId
|
||||
})
|
||||
.from(userOrgRoles)
|
||||
.where(eq(userOrgRoles.userId, userId));
|
||||
|
||||
const roleByOrg = new Map(
|
||||
roleRows.map((r) => [r.orgId, r.roleId])
|
||||
);
|
||||
const userOrganizations = userOrgRows.map((row) => ({
|
||||
...row,
|
||||
roleId: roleByOrg.get(row.orgId) ?? 0
|
||||
}));
|
||||
|
||||
return response<MyDeviceResponse>(res, {
|
||||
data: {
|
||||
user,
|
||||
|
||||
18
server/routers/user/types.ts
Normal file
18
server/routers/user/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { UserOrg } from "@server/db";
|
||||
|
||||
export type AddUserRoleResponse = {
|
||||
userId: string;
|
||||
roleId: number;
|
||||
};
|
||||
|
||||
/** Legacy POST /role/:roleId/add/:userId response shape (membership + effective role). */
|
||||
export type AddUserRoleLegacyResponse = UserOrg & { roleId: number };
|
||||
|
||||
export type SetUserOrgRolesParams = {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type SetUserOrgRolesBody = {
|
||||
roleIds: number[];
|
||||
};
|
||||
57
server/routers/user/updateUserLocale.ts
Normal file
57
server/routers/user/updateUserLocale.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
locale: z.string().min(2).max(10)
|
||||
});
|
||||
|
||||
export async function updateUserLocale(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not found")
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { locale } = parsedBody.data;
|
||||
|
||||
await db.update(users).set({ locale }).where(eq(users.userId, userId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User locale updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user