From 20e547a0f602cdc7f212b5f519436d1e6c140a73 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Tue, 24 Feb 2026 17:58:11 -0800
Subject: [PATCH 01/19] first pass
---
server/auth/actions.ts | 35 ++--
server/auth/canUserAccessResource.ts | 29 +--
server/auth/canUserAccessSiteResource.ts | 29 +--
server/db/pg/schema/schema.ts | 21 +-
server/db/queries/verifySessionQueries.ts | 48 ++++-
server/db/sqlite/schema/schema.ts | 28 ++-
server/index.ts | 2 +-
server/lib/calculateUserClientsForOrgs.ts | 33 ++-
server/lib/rebuildClientAssociations.ts | 15 +-
server/lib/userOrg.ts | 16 +-
server/lib/userOrgRoles.ts | 22 ++
server/middlewares/getUserOrgs.ts | 3 +-
server/middlewares/verifyAccessTokenAccess.ts | 8 +-
server/middlewares/verifyAdmin.ts | 25 ++-
server/middlewares/verifyApiKeyAccess.ts | 9 +-
server/middlewares/verifyClientAccess.ts | 38 ++--
server/middlewares/verifyDomainAccess.ts | 8 +-
server/middlewares/verifyOrgAccess.ts | 7 +-
server/middlewares/verifyResourceAccess.ts | 35 ++--
server/middlewares/verifyRoleAccess.ts | 4 +-
server/middlewares/verifySiteAccess.ts | 37 ++--
.../middlewares/verifySiteResourceAccess.ts | 38 ++--
server/middlewares/verifyTargetAccess.ts | 8 +-
server/middlewares/verifyUserInRole.ts | 6 +-
server/private/middlewares/verifyIdpAccess.ts | 9 +-
.../middlewares/verifyRemoteExitNodeAccess.ts | 13 +-
.../routers/org/sendUsageNotifications.ts | 18 +-
.../remoteExitNode/createRemoteExitNode.ts | 2 +-
server/private/routers/ssh/signSshKey.ts | 62 ++++--
.../routers/accessToken/listAccessTokens.ts | 2 +-
server/routers/badger/verifySession.ts | 33 ++-
server/routers/client/createClient.ts | 4 +-
server/routers/client/listClients.ts | 2 +-
server/routers/client/listUserDevices.ts | 2 +-
server/routers/external.ts | 10 +
server/routers/idp/validateOidcCallback.ts | 51 +++--
server/routers/integration.ts | 10 +
server/routers/newt/createNewt.ts | 2 +-
server/routers/olm/createOlm.ts | 2 +-
server/routers/org/checkOrgUserAccess.ts | 38 +++-
server/routers/org/createOrg.ts | 13 +-
server/routers/org/getOrgOverview.ts | 18 +-
server/routers/org/listUserOrgs.ts | 30 ++-
server/routers/resource/createResource.ts | 6 +-
server/routers/resource/getUserResources.ts | 46 ++--
server/routers/resource/listResources.ts | 2 +-
server/routers/role/deleteRole.ts | 8 +-
server/routers/site/createSite.ts | 4 +-
server/routers/site/listSites.ts | 2 +-
server/routers/user/acceptInvite.ts | 4 +-
server/routers/user/addUserRole.ts | 35 ++--
server/routers/user/createOrgUser.ts | 32 +--
server/routers/user/getOrgUser.ts | 38 +++-
server/routers/user/index.ts | 1 +
server/routers/user/listUsers.ts | 37 +++-
server/routers/user/myDevice.ts | 23 +-
server/routers/user/removeUserRole.ts | 157 ++++++++++++++
server/types/Auth.ts | 2 +-
.../users/[userId]/access-controls/page.tsx | 196 ++++++++++++------
.../[orgId]/settings/access/users/page.tsx | 4 +-
60 files changed, 1023 insertions(+), 399 deletions(-)
create mode 100644 server/lib/userOrgRoles.ts
create mode 100644 server/routers/user/removeUserRole.ts
diff --git a/server/auth/actions.ts b/server/auth/actions.ts
index 3f5a145b6..feb91560a 100644
--- a/server/auth/actions.ts
+++ b/server/auth/actions.ts
@@ -1,7 +1,7 @@
import { Request } from "express";
import { db } from "@server/db";
-import { userActions, roleActions, userOrgs } from "@server/db";
-import { and, eq } from "drizzle-orm";
+import { userActions, roleActions } from "@server/db";
+import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
@@ -52,6 +52,7 @@ export enum ActionsEnum {
listRoleResources = "listRoleResources",
// listRoleActions = "listRoleActions",
addUserRole = "addUserRole",
+ removeUserRole = "removeUserRole",
// addUserSite = "addUserSite",
// addUserAction = "addUserAction",
// removeUserAction = "removeUserAction",
@@ -153,29 +154,19 @@ export async function checkUserActionPermission(
}
try {
- let userOrgRoleId = req.userOrgRoleId;
+ let userOrgRoleIds = req.userOrgRoleIds;
- // If userOrgRoleId is not available on the request, fetch it
- if (userOrgRoleId === undefined) {
- const userOrgRole = await db
- .select()
- .from(userOrgs)
- .where(
- and(
- eq(userOrgs.userId, userId),
- eq(userOrgs.orgId, req.userOrgId!)
- )
- )
- .limit(1);
-
- if (userOrgRole.length === 0) {
+ if (userOrgRoleIds === undefined) {
+ const { getUserOrgRoleIds } = await import(
+ "@server/lib/userOrgRoles"
+ );
+ userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!);
+ if (userOrgRoleIds.length === 0) {
throw createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
);
}
-
- userOrgRoleId = userOrgRole[0].roleId;
}
// Check if the user has direct permission for the action in the current org
@@ -186,7 +177,7 @@ export async function checkUserActionPermission(
and(
eq(userActions.userId, userId),
eq(userActions.actionId, actionId),
- eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org
+ eq(userActions.orgId, req.userOrgId!)
)
)
.limit(1);
@@ -195,14 +186,14 @@ export async function checkUserActionPermission(
return true;
}
- // If no direct permission, check role-based permission
+ // If no direct permission, check role-based permission (any of user's roles)
const roleActionPermission = await db
.select()
.from(roleActions)
.where(
and(
eq(roleActions.actionId, actionId),
- eq(roleActions.roleId, userOrgRoleId!),
+ inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!)
)
)
diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts
index 161a0bee9..2c8911490 100644
--- a/server/auth/canUserAccessResource.ts
+++ b/server/auth/canUserAccessResource.ts
@@ -1,26 +1,29 @@
import { db } from "@server/db";
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { roleResources, userResources } from "@server/db";
export async function canUserAccessResource({
userId,
resourceId,
- roleId
+ roleIds
}: {
userId: string;
resourceId: number;
- roleId: number;
+ roleIds: number[];
}): Promise {
- const roleResourceAccess = await db
- .select()
- .from(roleResources)
- .where(
- and(
- eq(roleResources.resourceId, resourceId),
- eq(roleResources.roleId, roleId)
- )
- )
- .limit(1);
+ const roleResourceAccess =
+ roleIds.length > 0
+ ? await db
+ .select()
+ .from(roleResources)
+ .where(
+ and(
+ eq(roleResources.resourceId, resourceId),
+ inArray(roleResources.roleId, roleIds)
+ )
+ )
+ .limit(1)
+ : [];
if (roleResourceAccess.length > 0) {
return true;
diff --git a/server/auth/canUserAccessSiteResource.ts b/server/auth/canUserAccessSiteResource.ts
index 959b0eff6..7e6ec9bb8 100644
--- a/server/auth/canUserAccessSiteResource.ts
+++ b/server/auth/canUserAccessSiteResource.ts
@@ -1,26 +1,29 @@
import { db } from "@server/db";
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { roleSiteResources, userSiteResources } from "@server/db";
export async function canUserAccessSiteResource({
userId,
resourceId,
- roleId
+ roleIds
}: {
userId: string;
resourceId: number;
- roleId: number;
+ roleIds: number[];
}): Promise {
- const roleResourceAccess = await db
- .select()
- .from(roleSiteResources)
- .where(
- and(
- eq(roleSiteResources.siteResourceId, resourceId),
- eq(roleSiteResources.roleId, roleId)
- )
- )
- .limit(1);
+ const roleResourceAccess =
+ roleIds.length > 0
+ ? await db
+ .select()
+ .from(roleSiteResources)
+ .where(
+ and(
+ eq(roleSiteResources.siteResourceId, resourceId),
+ inArray(roleSiteResources.roleId, roleIds)
+ )
+ )
+ .limit(1)
+ : [];
if (roleResourceAccess.length > 0) {
return true;
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index ae90020a0..1d38bfb3e 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -9,6 +9,7 @@ import {
real,
serial,
text,
+ unique,
varchar
} from "drizzle-orm/pg-core";
@@ -332,9 +333,6 @@ export const userOrgs = pgTable("userOrgs", {
onDelete: "cascade"
})
.notNull(),
- roleId: integer("roleId")
- .notNull()
- .references(() => roles.roleId),
isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
@@ -383,6 +381,22 @@ export const roles = pgTable("roles", {
sshUnixGroups: text("sshUnixGroups").default("[]")
});
+export const userOrgRoles = pgTable(
+ "userOrgRoles",
+ {
+ userId: varchar("userId")
+ .notNull()
+ .references(() => users.userId, { onDelete: "cascade" }),
+ orgId: varchar("orgId")
+ .notNull()
+ .references(() => orgs.orgId, { onDelete: "cascade" }),
+ roleId: integer("roleId")
+ .notNull()
+ .references(() => roles.roleId, { onDelete: "cascade" })
+ },
+ (t) => [unique().on(t.userId, t.orgId, t.roleId)]
+);
+
export const roleActions = pgTable("roleActions", {
roleId: integer("roleId")
.notNull()
@@ -1031,6 +1045,7 @@ export type RoleResource = InferSelectModel;
export type UserResource = InferSelectModel;
export type UserInvite = InferSelectModel;
export type UserOrg = InferSelectModel;
+export type UserOrgRole = InferSelectModel;
export type ResourceSession = InferSelectModel;
export type ResourcePincode = InferSelectModel;
export type ResourcePassword = InferSelectModel;
diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts
index 280c8a119..469df590d 100644
--- a/server/db/queries/verifySessionQueries.ts
+++ b/server/db/queries/verifySessionQueries.ts
@@ -12,6 +12,7 @@ import {
resources,
roleResources,
sessions,
+ userOrgRoles,
userOrgs,
userResources,
users,
@@ -104,24 +105,57 @@ export async function getUserSessionWithUser(
}
/**
- * Get user organization role
+ * Get user organization role (single role; prefer getUserOrgRoleIds + roles for multi-role).
+ * @deprecated Use userOrgRoles table and getUserOrgRoleIds for multi-role support.
*/
export async function getUserOrgRole(userId: string, orgId: string) {
- const userOrgRole = await db
+ const userOrg = await db
.select({
userId: userOrgs.userId,
orgId: userOrgs.orgId,
- roleId: userOrgs.roleId,
isOwner: userOrgs.isOwner,
- autoProvisioned: userOrgs.autoProvisioned,
- roleName: roles.name
+ autoProvisioned: userOrgs.autoProvisioned
})
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
- .leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.limit(1);
- return userOrgRole.length > 0 ? userOrgRole[0] : null;
+ if (userOrg.length === 0) return null;
+
+ const [firstRole] = await db
+ .select({
+ roleId: userOrgRoles.roleId,
+ roleName: roles.name
+ })
+ .from(userOrgRoles)
+ .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, orgId)
+ )
+ )
+ .limit(1);
+
+ return firstRole
+ ? {
+ ...userOrg[0],
+ roleId: firstRole.roleId,
+ roleName: firstRole.roleName
+ }
+ : { ...userOrg[0], roleId: null, roleName: null };
+}
+
+/**
+ * Get role name by role ID (for display).
+ */
+export async function getRoleName(roleId: number): Promise {
+ const [row] = await db
+ .select({ name: roles.name })
+ .from(roles)
+ .where(eq(roles.roleId, roleId))
+ .limit(1);
+ return row?.name ?? null;
}
/**
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 64866e679..2d475808b 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -1,6 +1,12 @@
import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm";
-import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
+import {
+ index,
+ integer,
+ sqliteTable,
+ text,
+ unique
+} from "drizzle-orm/sqlite-core";
export const domains = sqliteTable("domains", {
domainId: text("domainId").primaryKey(),
@@ -635,9 +641,6 @@ export const userOrgs = sqliteTable("userOrgs", {
onDelete: "cascade"
})
.notNull(),
- roleId: integer("roleId")
- .notNull()
- .references(() => roles.roleId),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
autoProvisioned: integer("autoProvisioned", {
mode: "boolean"
@@ -692,6 +695,22 @@ export const roles = sqliteTable("roles", {
sshUnixGroups: text("sshUnixGroups").default("[]")
});
+export const userOrgRoles = sqliteTable(
+ "userOrgRoles",
+ {
+ userId: text("userId")
+ .notNull()
+ .references(() => users.userId, { onDelete: "cascade" }),
+ orgId: text("orgId")
+ .notNull()
+ .references(() => orgs.orgId, { onDelete: "cascade" }),
+ roleId: integer("roleId")
+ .notNull()
+ .references(() => roles.roleId, { onDelete: "cascade" })
+ },
+ (t) => [unique().on(t.userId, t.orgId, t.roleId)]
+);
+
export const roleActions = sqliteTable("roleActions", {
roleId: integer("roleId")
.notNull()
@@ -1126,6 +1145,7 @@ export type RoleResource = InferSelectModel;
export type UserResource = InferSelectModel;
export type UserInvite = InferSelectModel;
export type UserOrg = InferSelectModel;
+export type UserOrgRole = InferSelectModel;
export type ResourceSession = InferSelectModel;
export type ResourcePincode = InferSelectModel;
export type ResourcePassword = InferSelectModel;
diff --git a/server/index.ts b/server/index.ts
index a61daca7f..0fc44c279 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -74,7 +74,7 @@ declare global {
session: Session;
userOrg?: UserOrg;
apiKeyOrg?: ApiKeyOrg;
- userOrgRoleId?: number;
+ userOrgRoleIds?: number[];
userOrgId?: string;
userOrgIds?: string[];
remoteExitNode?: RemoteExitNode;
diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts
index 4be76dddc..02ac0c417 100644
--- a/server/lib/calculateUserClientsForOrgs.ts
+++ b/server/lib/calculateUserClientsForOrgs.ts
@@ -10,6 +10,7 @@ import {
roles,
Transaction,
userClients,
+ userOrgRoles,
userOrgs
} from "@server/db";
import { getUniqueClientName } from "@server/db/names";
@@ -39,20 +40,36 @@ export async function calculateUserClientsForOrgs(
return;
}
- // Get all user orgs
- const allUserOrgs = await transaction
+ // Get all user orgs with all roles (for org list and role-based logic)
+ const userOrgRoleRows = await transaction
.select()
.from(userOrgs)
- .innerJoin(roles, eq(roles.roleId, userOrgs.roleId))
+ .innerJoin(
+ userOrgRoles,
+ and(
+ eq(userOrgs.userId, userOrgRoles.userId),
+ eq(userOrgs.orgId, userOrgRoles.orgId)
+ )
+ )
+ .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
- const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId);
+ const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))];
+ const orgIdToRoleRows = new Map<
+ string,
+ (typeof userOrgRoleRows)[0][]
+ >();
+ for (const r of userOrgRoleRows) {
+ const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
+ list.push(r);
+ orgIdToRoleRows.set(r.userOrgs.orgId, list);
+ }
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
- for (const userRoleOrg of allUserOrgs) {
- const { userOrgs: userOrg, roles: role } = userRoleOrg;
- const orgId = userOrg.orgId;
+ for (const orgId of orgIdToRoleRows.keys()) {
+ const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
+ const userOrg = roleRowsForOrg[0].userOrgs;
const [org] = await transaction
.select()
@@ -196,7 +213,7 @@ export async function calculateUserClientsForOrgs(
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
- role.requireDeviceApproval;
+ roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
const newClientData: InferInsertModel = {
userId,
diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts
index 625e57935..7ec767492 100644
--- a/server/lib/rebuildClientAssociations.ts
+++ b/server/lib/rebuildClientAssociations.ts
@@ -14,6 +14,7 @@ import {
siteResources,
sites,
Transaction,
+ userOrgRoles,
userOrgs,
userSiteResources
} from "@server/db";
@@ -77,10 +78,10 @@ export async function getClientSiteResourceAccess(
// get all of the users in these roles
const userIdsFromRoles = await trx
.select({
- userId: userOrgs.userId
+ userId: userOrgRoles.userId
})
- .from(userOrgs)
- .where(inArray(userOrgs.roleId, roleIds))
+ .from(userOrgRoles)
+ .where(inArray(userOrgRoles.roleId, roleIds))
.then((rows) => rows.map((row) => row.userId));
const newAllUserIds = Array.from(
@@ -811,12 +812,12 @@ export async function rebuildClientAssociationsFromClient(
// Role-based access
const roleIds = await trx
- .select({ roleId: userOrgs.roleId })
- .from(userOrgs)
+ .select({ roleId: userOrgRoles.roleId })
+ .from(userOrgRoles)
.where(
and(
- eq(userOrgs.userId, client.userId),
- eq(userOrgs.orgId, client.orgId)
+ eq(userOrgRoles.userId, client.userId),
+ eq(userOrgRoles.orgId, client.orgId)
)
) // this needs to be locked onto this org or else cross-org access could happen
.then((rows) => rows.map((row) => row.roleId));
diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts
index 6ed10039b..fb0b88c2b 100644
--- a/server/lib/userOrg.ts
+++ b/server/lib/userOrg.ts
@@ -6,7 +6,7 @@ import {
siteResources,
sites,
Transaction,
- UserOrg,
+ userOrgRoles,
userOrgs,
userResources,
userSiteResources,
@@ -19,9 +19,15 @@ import { FeatureId } from "@server/lib/billing";
export async function assignUserToOrg(
org: Org,
values: typeof userOrgs.$inferInsert,
+ roleId: number,
trx: Transaction | typeof db = db
) {
const [userOrg] = await trx.insert(userOrgs).values(values).returning();
+ await trx.insert(userOrgRoles).values({
+ userId: userOrg.userId,
+ orgId: userOrg.orgId,
+ roleId
+ });
// calculate if the user is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
@@ -58,6 +64,14 @@ export async function removeUserFromOrg(
userId: string,
trx: Transaction | typeof db = db
) {
+ await trx
+ .delete(userOrgRoles)
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, org.orgId)
+ )
+ );
await trx
.delete(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
diff --git a/server/lib/userOrgRoles.ts b/server/lib/userOrgRoles.ts
new file mode 100644
index 000000000..5a4d75659
--- /dev/null
+++ b/server/lib/userOrgRoles.ts
@@ -0,0 +1,22 @@
+import { db, userOrgRoles } from "@server/db";
+import { and, eq } from "drizzle-orm";
+
+/**
+ * Get all role IDs a user has in an organization.
+ * Returns empty array if the user has no roles in the org (callers must treat as no access).
+ */
+export async function getUserOrgRoleIds(
+ userId: string,
+ orgId: string
+): Promise {
+ const rows = await db
+ .select({ roleId: userOrgRoles.roleId })
+ .from(userOrgRoles)
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, orgId)
+ )
+ );
+ return rows.map((r) => r.roleId);
+}
diff --git a/server/middlewares/getUserOrgs.ts b/server/middlewares/getUserOrgs.ts
index d7905700e..fa9794fb9 100644
--- a/server/middlewares/getUserOrgs.ts
+++ b/server/middlewares/getUserOrgs.ts
@@ -21,8 +21,7 @@ export async function getUserOrgs(
try {
const userOrganizations = await db
.select({
- orgId: userOrgs.orgId,
- roleId: userOrgs.roleId
+ orgId: userOrgs.orgId
})
.from(userOrgs)
.where(eq(userOrgs.userId, userId));
diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts
index 033b326d9..f1f2ca52e 100644
--- a/server/middlewares/verifyAccessTokenAccess.ts
+++ b/server/middlewares/verifyAccessTokenAccess.ts
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyAccessTokenAccess(
req: Request,
@@ -93,7 +94,10 @@ export async function verifyAccessTokenAccess(
)
);
} else {
- req.userOrgRoleId = req.userOrg.roleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ resource[0].orgId!
+ );
req.userOrgId = resource[0].orgId!;
}
@@ -118,7 +122,7 @@ export async function verifyAccessTokenAccess(
const resourceAllowed = await canUserAccessResource({
userId,
resourceId,
- roleId: req.userOrgRoleId!
+ roleIds: req.userOrgRoleIds ?? []
});
if (!resourceAllowed) {
diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts
index 253bfc2dd..0dbeac2cb 100644
--- a/server/middlewares/verifyAdmin.ts
+++ b/server/middlewares/verifyAdmin.ts
@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { roles, userOrgs } from "@server/db";
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyAdmin(
req: Request,
@@ -62,13 +63,29 @@ export async function verifyAdmin(
}
}
- const userRole = await db
+ req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId!);
+
+ if (req.userOrgRoleIds.length === 0) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "User does not have Admin access"
+ )
+ );
+ }
+
+ const userAdminRoles = await db
.select()
.from(roles)
- .where(eq(roles.roleId, req.userOrg.roleId))
+ .where(
+ and(
+ inArray(roles.roleId, req.userOrgRoleIds),
+ eq(roles.isAdmin, true)
+ )
+ )
.limit(1);
- if (userRole.length === 0 || !userRole[0].isAdmin) {
+ if (userAdminRoles.length === 0) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts
index 6edc5ab8e..b497892c8 100644
--- a/server/middlewares/verifyApiKeyAccess.ts
+++ b/server/middlewares/verifyApiKeyAccess.ts
@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { userOrgs, apiKeys, apiKeyOrg } from "@server/db";
-import { and, eq, or } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyApiKeyAccess(
req: Request,
@@ -103,8 +104,10 @@ export async function verifyApiKeyAccess(
}
}
- const userOrgRoleId = req.userOrg.roleId;
- req.userOrgRoleId = userOrgRoleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ orgId
+ );
return next();
} catch (error) {
diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts
index d2df38a4b..1d994b53f 100644
--- a/server/middlewares/verifyClientAccess.ts
+++ b/server/middlewares/verifyClientAccess.ts
@@ -1,11 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { Client, db } from "@server/db";
import { userOrgs, clients, roleClients, userClients } from "@server/db";
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import logger from "@server/logger";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyClientAccess(
req: Request,
@@ -113,21 +114,30 @@ export async function verifyClientAccess(
}
}
- const userOrgRoleId = req.userOrg.roleId;
- req.userOrgRoleId = userOrgRoleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ client.orgId
+ );
req.userOrgId = client.orgId;
- // Check role-based site access first
- const [roleClientAccess] = await db
- .select()
- .from(roleClients)
- .where(
- and(
- eq(roleClients.clientId, client.clientId),
- eq(roleClients.roleId, userOrgRoleId)
- )
- )
- .limit(1);
+ // Check role-based client access (any of user's roles)
+ const roleClientAccessList =
+ (req.userOrgRoleIds?.length ?? 0) > 0
+ ? await db
+ .select()
+ .from(roleClients)
+ .where(
+ and(
+ eq(roleClients.clientId, client.clientId),
+ inArray(
+ roleClients.roleId,
+ req.userOrgRoleIds!
+ )
+ )
+ )
+ .limit(1)
+ : [];
+ const [roleClientAccess] = roleClientAccessList;
if (roleClientAccess) {
// User has access to the site through their role
diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts
index 88ffe678d..c9ecf42e0 100644
--- a/server/middlewares/verifyDomainAccess.ts
+++ b/server/middlewares/verifyDomainAccess.ts
@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { db, domains, orgDomains } from "@server/db";
-import { userOrgs, apiKeyOrg } from "@server/db";
+import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyDomainAccess(
req: Request,
@@ -63,7 +64,7 @@ export async function verifyDomainAccess(
.where(
and(
eq(userOrgs.userId, userId),
- eq(userOrgs.orgId, apiKeyOrg.orgId)
+ eq(userOrgs.orgId, orgId)
)
)
.limit(1);
@@ -97,8 +98,7 @@ export async function verifyDomainAccess(
}
}
- const userOrgRoleId = req.userOrg.roleId;
- req.userOrgRoleId = userOrgRoleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
return next();
} catch (error) {
diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts
index 729766abd..cb797afb0 100644
--- a/server/middlewares/verifyOrgAccess.ts
+++ b/server/middlewares/verifyOrgAccess.ts
@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express";
-import { db, orgs } from "@server/db";
+import { db } from "@server/db";
import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyOrgAccess(
req: Request,
@@ -64,8 +65,8 @@ export async function verifyOrgAccess(
}
}
- // User has access, attach the user's role to the request for potential future use
- req.userOrgRoleId = req.userOrg.roleId;
+ // User has access, attach the user's role(s) to the request for potential future use
+ req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgId = orgId;
return next();
diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts
index 2ae591ee1..ba49f02e3 100644
--- a/server/middlewares/verifyResourceAccess.ts
+++ b/server/middlewares/verifyResourceAccess.ts
@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { db, Resource } from "@server/db";
import { resources, userOrgs, userResources, roleResources } from "@server/db";
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyResourceAccess(
req: Request,
@@ -107,20 +108,28 @@ export async function verifyResourceAccess(
}
}
- const userOrgRoleId = req.userOrg.roleId;
- req.userOrgRoleId = userOrgRoleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ resource.orgId
+ );
req.userOrgId = resource.orgId;
- const roleResourceAccess = await db
- .select()
- .from(roleResources)
- .where(
- and(
- eq(roleResources.resourceId, resource.resourceId),
- eq(roleResources.roleId, userOrgRoleId)
- )
- )
- .limit(1);
+ const roleResourceAccess =
+ (req.userOrgRoleIds?.length ?? 0) > 0
+ ? await db
+ .select()
+ .from(roleResources)
+ .where(
+ and(
+ eq(roleResources.resourceId, resource.resourceId),
+ inArray(
+ roleResources.roleId,
+ req.userOrgRoleIds!
+ )
+ )
+ )
+ .limit(1)
+ : [];
if (roleResourceAccess.length > 0) {
return next();
diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts
index 8858ab53f..380b82048 100644
--- a/server/middlewares/verifyRoleAccess.ts
+++ b/server/middlewares/verifyRoleAccess.ts
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyRoleAccess(
req: Request,
@@ -99,7 +100,6 @@ export async function verifyRoleAccess(
}
if (!req.userOrg) {
- // get the userORg
const userOrg = await db
.select()
.from(userOrgs)
@@ -109,7 +109,7 @@ export async function verifyRoleAccess(
.limit(1);
req.userOrg = userOrg[0];
- req.userOrgRoleId = userOrg[0].roleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(userId, orgId!);
}
if (!req.userOrg) {
diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts
index 98858cfb9..e630cf0f1 100644
--- a/server/middlewares/verifySiteAccess.ts
+++ b/server/middlewares/verifySiteAccess.ts
@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db";
-import { and, eq, or } from "drizzle-orm";
+import { and, eq, inArray, or } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifySiteAccess(
req: Request,
@@ -112,21 +113,29 @@ export async function verifySiteAccess(
}
}
- const userOrgRoleId = req.userOrg.roleId;
- req.userOrgRoleId = userOrgRoleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ site.orgId
+ );
req.userOrgId = site.orgId;
- // Check role-based site access first
- const roleSiteAccess = await db
- .select()
- .from(roleSites)
- .where(
- and(
- eq(roleSites.siteId, site.siteId),
- eq(roleSites.roleId, userOrgRoleId)
- )
- )
- .limit(1);
+ // Check role-based site access first (any of user's roles)
+ const roleSiteAccess =
+ (req.userOrgRoleIds?.length ?? 0) > 0
+ ? await db
+ .select()
+ .from(roleSites)
+ .where(
+ and(
+ eq(roleSites.siteId, site.siteId),
+ inArray(
+ roleSites.roleId,
+ req.userOrgRoleIds!
+ )
+ )
+ )
+ .limit(1)
+ : [];
if (roleSiteAccess.length > 0) {
// User's role has access to the site
diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts
index ca7d37fb3..8d5bd656f 100644
--- a/server/middlewares/verifySiteResourceAccess.ts
+++ b/server/middlewares/verifySiteResourceAccess.ts
@@ -1,11 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db";
import { siteResources } from "@server/db";
-import { eq, and } from "drizzle-orm";
+import { eq, and, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifySiteResourceAccess(
req: Request,
@@ -109,23 +110,34 @@ export async function verifySiteResourceAccess(
}
}
- const userOrgRoleId = req.userOrg.roleId;
- req.userOrgRoleId = userOrgRoleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ siteResource.orgId
+ );
req.userOrgId = siteResource.orgId;
// Attach the siteResource to the request for use in the next middleware/route
req.siteResource = siteResource;
- const roleResourceAccess = await db
- .select()
- .from(roleSiteResources)
- .where(
- and(
- eq(roleSiteResources.siteResourceId, siteResourceIdNum),
- eq(roleSiteResources.roleId, userOrgRoleId)
- )
- )
- .limit(1);
+ const roleResourceAccess =
+ (req.userOrgRoleIds?.length ?? 0) > 0
+ ? await db
+ .select()
+ .from(roleSiteResources)
+ .where(
+ and(
+ eq(
+ roleSiteResources.siteResourceId,
+ siteResourceIdNum
+ ),
+ inArray(
+ roleSiteResources.roleId,
+ req.userOrgRoleIds!
+ )
+ )
+ )
+ .limit(1)
+ : [];
if (roleResourceAccess.length > 0) {
return next();
diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts
index 7e433fcb8..141a04549 100644
--- a/server/middlewares/verifyTargetAccess.ts
+++ b/server/middlewares/verifyTargetAccess.ts
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyTargetAccess(
req: Request,
@@ -99,7 +100,10 @@ export async function verifyTargetAccess(
)
);
} else {
- req.userOrgRoleId = req.userOrg.roleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ resource[0].orgId!
+ );
req.userOrgId = resource[0].orgId!;
}
@@ -126,7 +130,7 @@ export async function verifyTargetAccess(
const resourceAllowed = await canUserAccessResource({
userId,
resourceId,
- roleId: req.userOrgRoleId!
+ roleIds: req.userOrgRoleIds ?? []
});
if (!resourceAllowed) {
diff --git a/server/middlewares/verifyUserInRole.ts b/server/middlewares/verifyUserInRole.ts
index 2a153114d..18eeb44f3 100644
--- a/server/middlewares/verifyUserInRole.ts
+++ b/server/middlewares/verifyUserInRole.ts
@@ -12,7 +12,7 @@ export async function verifyUserInRole(
const roleId = parseInt(
req.params.roleId || req.body.roleId || req.query.roleId
);
- const userRoleId = req.userOrgRoleId;
+ const userOrgRoleIds = req.userOrgRoleIds ?? [];
if (isNaN(roleId)) {
return next(
@@ -20,7 +20,7 @@ export async function verifyUserInRole(
);
}
- if (!userRoleId) {
+ if (userOrgRoleIds.length === 0) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -29,7 +29,7 @@ export async function verifyUserInRole(
);
}
- if (userRoleId !== roleId) {
+ if (!userOrgRoleIds.includes(roleId)) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
diff --git a/server/private/middlewares/verifyIdpAccess.ts b/server/private/middlewares/verifyIdpAccess.ts
index 410956844..2dbc1b8ff 100644
--- a/server/private/middlewares/verifyIdpAccess.ts
+++ b/server/private/middlewares/verifyIdpAccess.ts
@@ -13,9 +13,10 @@
import { Request, Response, NextFunction } from "express";
import { userOrgs, db, idp, idpOrg } from "@server/db";
-import { and, eq, or } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyIdpAccess(
req: Request,
@@ -84,8 +85,10 @@ export async function verifyIdpAccess(
);
}
- const userOrgRoleId = req.userOrg.roleId;
- req.userOrgRoleId = userOrgRoleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ idpRes.idpOrg.orgId
+ );
return next();
} catch (error) {
diff --git a/server/private/middlewares/verifyRemoteExitNodeAccess.ts b/server/private/middlewares/verifyRemoteExitNodeAccess.ts
index a2cd2bace..7d6128d8f 100644
--- a/server/private/middlewares/verifyRemoteExitNodeAccess.ts
+++ b/server/private/middlewares/verifyRemoteExitNodeAccess.ts
@@ -12,11 +12,12 @@
*/
import { Request, Response, NextFunction } from "express";
-import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db";
-import { sites, userOrgs, userSites, roleSites, roles } from "@server/db";
-import { and, eq, or } from "drizzle-orm";
+import { db, exitNodeOrgs, remoteExitNodes } from "@server/db";
+import { userOrgs } from "@server/db";
+import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyRemoteExitNodeAccess(
req: Request,
@@ -103,8 +104,10 @@ export async function verifyRemoteExitNodeAccess(
);
}
- const userOrgRoleId = req.userOrg.roleId;
- req.userOrgRoleId = userOrgRoleId;
+ req.userOrgRoleIds = await getUserOrgRoleIds(
+ req.userOrg.userId,
+ exitNodeOrg.orgId
+ );
return next();
} catch (error) {
diff --git a/server/private/routers/org/sendUsageNotifications.ts b/server/private/routers/org/sendUsageNotifications.ts
index 4aa421520..72fc00d4c 100644
--- a/server/private/routers/org/sendUsageNotifications.ts
+++ b/server/private/routers/org/sendUsageNotifications.ts
@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
-import { userOrgs, users, roles, orgs } from "@server/db";
+import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db";
import { eq, and, or } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -95,7 +95,14 @@ async function getOrgAdmins(orgId: string) {
})
.from(userOrgs)
.innerJoin(users, eq(userOrgs.userId, users.userId))
- .leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
+ .leftJoin(
+ userOrgRoles,
+ and(
+ eq(userOrgs.userId, userOrgRoles.userId),
+ eq(userOrgs.orgId, userOrgRoles.orgId)
+ )
+ )
+ .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgs.orgId, orgId),
@@ -103,8 +110,11 @@ async function getOrgAdmins(orgId: string) {
)
);
- // Filter to only include users with verified emails
- const orgAdmins = admins.filter(
+ // Dedupe by userId (user may have multiple roles)
+ const byUserId = new Map(
+ admins.map((a) => [a.userId, a])
+ );
+ const orgAdmins = Array.from(byUserId.values()).filter(
(admin) => admin.email && admin.email.length > 0
);
diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts
index 6d5b5ea6f..f24afdde1 100644
--- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts
+++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts
@@ -79,7 +79,7 @@ export async function createRemoteExitNode(
const { remoteExitNodeId, secret } = parsedBody.data;
- if (req.user && !req.userOrgRoleId) {
+ if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts
index fbdee72d1..f45db3c85 100644
--- a/server/private/routers/ssh/signSshKey.ts
+++ b/server/private/routers/ssh/signSshKey.ts
@@ -30,7 +30,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
-import { eq, or, and } from "drizzle-orm";
+import { and, eq, inArray, or } from "drizzle-orm";
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
import config from "@server/lib/config";
@@ -122,7 +122,7 @@ export async function signSshKey(
resource: resourceQueryString
} = parsedBody.data;
const userId = req.user?.userId;
- const roleId = req.userOrgRoleId!;
+ const roleIds = req.userOrgRoleIds ?? [];
if (!userId) {
return next(
@@ -130,6 +130,15 @@ export async function signSshKey(
);
}
+ if (roleIds.length === 0) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "User has no role in organization"
+ )
+ );
+ }
+
const [userOrg] = await db
.select()
.from(userOrgs)
@@ -310,11 +319,11 @@ export async function signSshKey(
);
}
- // Check if the user has access to the resource
+ // Check if the user has access to the resource (any of their roles)
const hasAccess = await canUserAccessSiteResource({
userId: userId,
resourceId: resource.siteResourceId,
- roleId: roleId
+ roleIds
});
if (!hasAccess) {
@@ -326,28 +335,39 @@ export async function signSshKey(
);
}
- const [roleRow] = await db
+ const roleRows = await db
.select()
.from(roles)
- .where(eq(roles.roleId, roleId))
- .limit(1);
+ .where(inArray(roles.roleId, roleIds));
- let parsedSudoCommands: string[] = [];
- let parsedGroups: string[] = [];
- try {
- parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
- if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = [];
- } catch {
- parsedSudoCommands = [];
+ const parsedSudoCommands: string[] = [];
+ const parsedGroupsSet = new Set();
+ let homedir: boolean | null = null;
+ const sudoModeOrder = { none: 0, commands: 1, all: 2 };
+ let sudoMode: "none" | "commands" | "all" = "none";
+ for (const roleRow of roleRows) {
+ try {
+ const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
+ if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds);
+ } catch {
+ // skip
+ }
+ try {
+ const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
+ if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g));
+ } catch {
+ // skip
+ }
+ if (roleRow?.sshCreateHomeDir === true) homedir = true;
+ const m = roleRow?.sshSudoMode ?? "none";
+ if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) {
+ sudoMode = m as "none" | "commands" | "all";
+ }
}
- try {
- parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
- if (!Array.isArray(parsedGroups)) parsedGroups = [];
- } catch {
- parsedGroups = [];
+ const parsedGroups = Array.from(parsedGroupsSet);
+ if (homedir === null && roleRows.length > 0) {
+ homedir = roleRows[0].sshCreateHomeDir ?? null;
}
- const homedir = roleRow?.sshCreateHomeDir ?? null;
- const sudoMode = roleRow?.sshSudoMode ?? "none";
// get the site
const [newt] = await db
diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts
index 2f929fc62..495afeb3c 100644
--- a/server/routers/accessToken/listAccessTokens.ts
+++ b/server/routers/accessToken/listAccessTokens.ts
@@ -208,7 +208,7 @@ export async function listAccessTokens(
.where(
or(
eq(userResources.userId, req.user!.userId),
- eq(roleResources.roleId, req.userOrgRoleId!)
+ inArray(roleResources.roleId, req.userOrgRoleIds!)
)
);
} else {
diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts
index b5c66c0e9..2f6f7ac12 100644
--- a/server/routers/badger/verifySession.ts
+++ b/server/routers/badger/verifySession.ts
@@ -3,12 +3,13 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
import {
getResourceByDomain,
getResourceRules,
+ getRoleName,
getRoleResourceAccess,
- getUserOrgRole,
getUserResourceAccess,
getOrgLoginPage,
getUserSessionWithUser
} from "@server/db/queries/verifySessionQueries";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import {
LoginPage,
Org,
@@ -916,9 +917,9 @@ async function isUserAllowedToAccessResource(
return null;
}
- const userOrgRole = await getUserOrgRole(user.userId, resource.orgId);
+ const userOrgRoleIds = await getUserOrgRoleIds(user.userId, resource.orgId);
- if (!userOrgRole) {
+ if (!userOrgRoleIds.length) {
return null;
}
@@ -934,17 +935,23 @@ async function isUserAllowedToAccessResource(
return null;
}
- const roleResourceAccess = await getRoleResourceAccess(
- resource.resourceId,
- userOrgRole.roleId
- );
-
- if (roleResourceAccess) {
+ const roleNames: string[] = [];
+ for (const roleId of userOrgRoleIds) {
+ const roleResourceAccess = await getRoleResourceAccess(
+ resource.resourceId,
+ roleId
+ );
+ if (roleResourceAccess) {
+ const roleName = await getRoleName(roleId);
+ if (roleName) roleNames.push(roleName);
+ }
+ }
+ if (roleNames.length > 0) {
return {
username: user.username,
email: user.email,
name: user.name,
- role: userOrgRole.roleName
+ role: roleNames.join(", ")
};
}
@@ -954,11 +961,15 @@ async function isUserAllowedToAccessResource(
);
if (userResourceAccess) {
+ const names = await Promise.all(
+ userOrgRoleIds.map((id) => getRoleName(id))
+ );
+ const role = names.filter(Boolean).join(", ") || "";
return {
username: user.username,
email: user.email,
name: user.name,
- role: userOrgRole.roleName
+ role
};
}
diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts
index 4eafb0616..3e5ba4fa1 100644
--- a/server/routers/client/createClient.ts
+++ b/server/routers/client/createClient.ts
@@ -92,7 +92,7 @@ export async function createClient(
const { orgId } = parsedParams.data;
- if (req.user && !req.userOrgRoleId) {
+ if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -234,7 +234,7 @@ export async function createClient(
clientId: newClient.clientId
});
- if (req.user && req.userOrgRoleId != adminRole.roleId) {
+ if (req.user && !req.userOrgRoleIds?.includes(adminRole.roleId)) {
// make sure the user can access the client
trx.insert(userClients).values({
userId: req.user.userId,
diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts
index 53a66150c..95d6281bf 100644
--- a/server/routers/client/listClients.ts
+++ b/server/routers/client/listClients.ts
@@ -297,7 +297,7 @@ export async function listClients(
.where(
or(
eq(userClients.userId, req.user!.userId),
- eq(roleClients.roleId, req.userOrgRoleId!)
+ inArray(roleClients.roleId, req.userOrgRoleIds!)
)
);
} else {
diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts
index 54fffe43b..4d37dc440 100644
--- a/server/routers/client/listUserDevices.ts
+++ b/server/routers/client/listUserDevices.ts
@@ -316,7 +316,7 @@ export async function listUserDevices(
.where(
or(
eq(userClients.userId, req.user!.userId),
- eq(roleClients.roleId, req.userOrgRoleId!)
+ inArray(roleClients.roleId, req.userOrgRoleIds!)
)
);
} else {
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 45ab58bba..bae7bb4db 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -654,6 +654,16 @@ authenticated.post(
user.addUserRole
);
+authenticated.delete(
+ "/role/:roleId/remove/:userId",
+ verifyRoleAccess,
+ verifyUserAccess,
+ verifyLimits,
+ verifyUserHasAction(ActionsEnum.removeUserRole),
+ logActionAudit(ActionsEnum.removeUserRole),
+ user.removeUserRole
+);
+
authenticated.post(
"/resource/:resourceId/roles",
verifyResourceAccess,
diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts
index e34621856..8714c4d38 100644
--- a/server/routers/idp/validateOidcCallback.ts
+++ b/server/routers/idp/validateOidcCallback.ts
@@ -13,6 +13,7 @@ import {
orgs,
Role,
roles,
+ userOrgRoles,
userOrgs,
users
} from "@server/db";
@@ -570,32 +571,28 @@ export async function validateOidcCallback(
}
}
- // Update roles for existing auto-provisioned orgs where the role has changed
- const orgsToUpdate = autoProvisionedOrgs.filter(
- (currentOrg) => {
- const newOrg = userOrgInfo.find(
- (newOrg) => newOrg.orgId === currentOrg.orgId
- );
- return newOrg && newOrg.roleId !== currentOrg.roleId;
- }
- );
-
- if (orgsToUpdate.length > 0) {
- for (const org of orgsToUpdate) {
- const newRole = userOrgInfo.find(
- (newOrg) => newOrg.orgId === org.orgId
- );
- if (newRole) {
- await trx
- .update(userOrgs)
- .set({ roleId: newRole.roleId })
- .where(
- and(
- eq(userOrgs.userId, userId!),
- eq(userOrgs.orgId, org.orgId)
- )
- );
- }
+ // Ensure IDP-provided role exists for existing auto-provisioned orgs (add only; never delete other roles)
+ const userRolesInOrgs = await trx
+ .select()
+ .from(userOrgRoles)
+ .where(eq(userOrgRoles.userId, userId!));
+ for (const currentOrg of autoProvisionedOrgs) {
+ const newRole = userOrgInfo.find(
+ (newOrg) => newOrg.orgId === currentOrg.orgId
+ );
+ if (!newRole) continue;
+ const currentRolesInOrg = userRolesInOrgs.filter(
+ (r) => r.orgId === currentOrg.orgId
+ );
+ const hasIdpRole = currentRolesInOrg.some(
+ (r) => r.roleId === newRole.roleId
+ );
+ if (!hasIdpRole) {
+ await trx.insert(userOrgRoles).values({
+ userId: userId!,
+ orgId: currentOrg.orgId,
+ roleId: newRole.roleId
+ });
}
}
@@ -619,9 +616,9 @@ export async function validateOidcCallback(
{
orgId: org.orgId,
userId: userId!,
- roleId: org.roleId,
autoProvisioned: true,
},
+ org.roleId,
trx
);
}
diff --git a/server/routers/integration.ts b/server/routers/integration.ts
index 6c39fe983..56e44c661 100644
--- a/server/routers/integration.ts
+++ b/server/routers/integration.ts
@@ -532,6 +532,16 @@ authenticated.post(
user.addUserRole
);
+authenticated.delete(
+ "/role/:roleId/remove/:userId",
+ verifyApiKeyRoleAccess,
+ verifyApiKeyUserAccess,
+ verifyLimits,
+ verifyApiKeyHasAction(ActionsEnum.removeUserRole),
+ logActionAudit(ActionsEnum.removeUserRole),
+ user.removeUserRole
+);
+
authenticated.post(
"/resource/:resourceId/roles",
verifyApiKeyResourceAccess,
diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts
index b5da405e6..68cdcacf8 100644
--- a/server/routers/newt/createNewt.ts
+++ b/server/routers/newt/createNewt.ts
@@ -46,7 +46,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data;
- if (req.user && !req.userOrgRoleId) {
+ if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
diff --git a/server/routers/olm/createOlm.ts b/server/routers/olm/createOlm.ts
index b5da405e6..68cdcacf8 100644
--- a/server/routers/olm/createOlm.ts
+++ b/server/routers/olm/createOlm.ts
@@ -46,7 +46,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data;
- if (req.user && !req.userOrgRoleId) {
+ if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts
index d9f0364e3..19e39c4fe 100644
--- a/server/routers/org/checkOrgUserAccess.ts
+++ b/server/routers/org/checkOrgUserAccess.ts
@@ -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";
@@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy";
async function queryUser(orgId: string, userId: string) {
- const [user] = await db
+ const [userRow] = await db
.select({
orgId: userOrgs.orgId,
userId: users.userId,
@@ -22,10 +22,7 @@ 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,
@@ -35,13 +32,40 @@ 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 CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult;
diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts
index 1a5d8799f..22dc742fa 100644
--- a/server/routers/org/createOrg.ts
+++ b/server/routers/org/createOrg.ts
@@ -9,6 +9,7 @@ import {
orgs,
roleActions,
roles,
+ userOrgRoles,
userOrgs,
users,
actions
@@ -312,9 +313,13 @@ export async function createOrg(
await trx.insert(userOrgs).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
- roleId: roleId,
isOwner: true
});
+ await trx.insert(userOrgRoles).values({
+ userId: req.user!.userId,
+ orgId: newOrg[0].orgId,
+ roleId
+ });
ownerUserId = req.user!.userId;
} else {
// if org created by root api key, set the server admin as the owner
@@ -332,9 +337,13 @@ export async function createOrg(
await trx.insert(userOrgs).values({
userId: serverAdmin.userId,
orgId: newOrg[0].orgId,
- roleId: roleId,
isOwner: true
});
+ await trx.insert(userOrgRoles).values({
+ userId: serverAdmin.userId,
+ orgId: newOrg[0].orgId,
+ roleId
+ });
ownerUserId = serverAdmin.userId;
}
diff --git a/server/routers/org/getOrgOverview.ts b/server/routers/org/getOrgOverview.ts
index d368d1b3c..fcdd7c0ed 100644
--- a/server/routers/org/getOrgOverview.ts
+++ b/server/routers/org/getOrgOverview.ts
@@ -117,20 +117,26 @@ export async function getOrgOverview(
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
- const [role] = await db
- .select()
- .from(roles)
- .where(eq(roles.roleId, req.userOrg.roleId));
+ const roleIds = req.userOrgRoleIds ?? [];
+ const roleRows =
+ roleIds.length > 0
+ ? await db
+ .select({ name: roles.name, isAdmin: roles.isAdmin })
+ .from(roles)
+ .where(inArray(roles.roleId, roleIds))
+ : [];
+ const userRoleName = roleRows.map((r) => r.name ?? "").join(", ") ?? "";
+ const isAdmin = roleRows.some((r) => r.isAdmin === true);
return response(res, {
data: {
orgName: org[0].name,
orgId: org[0].orgId,
- userRoleName: role.name,
+ userRoleName,
numSites,
numUsers,
numResources,
- isAdmin: role.isAdmin || false,
+ isAdmin,
isOwner: req.userOrg?.isOwner || false
},
success: true,
diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts
index 301d0203e..8e6ce649d 100644
--- a/server/routers/org/listUserOrgs.ts
+++ b/server/routers/org/listUserOrgs.ts
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, roles } from "@server/db";
-import { Org, orgs, userOrgs } from "@server/db";
+import { Org, orgs, userOrgRoles, userOrgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -82,10 +82,7 @@ export async function listUserOrgs(
const { userId } = parsedParams.data;
const userOrganizations = await db
- .select({
- orgId: userOrgs.orgId,
- roleId: userOrgs.roleId
- })
+ .select({ orgId: userOrgs.orgId })
.from(userOrgs)
.where(eq(userOrgs.userId, userId));
@@ -116,10 +113,27 @@ export async function listUserOrgs(
userOrgs,
and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId))
)
- .leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.limit(limit)
.offset(offset);
+ const roleRows = await db
+ .select({
+ orgId: userOrgRoles.orgId,
+ isAdmin: roles.isAdmin
+ })
+ .from(userOrgRoles)
+ .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ inArray(userOrgRoles.orgId, userOrgIds)
+ )
+ );
+
+ const orgHasAdmin = new Set(
+ roleRows.filter((r) => r.isAdmin).map((r) => r.orgId)
+ );
+
const totalCountResult = await db
.select({ count: sql`cast(count(*) as integer)` })
.from(orgs)
@@ -133,8 +147,8 @@ export async function listUserOrgs(
if (val.userOrgs && val.userOrgs.isOwner) {
res.isOwner = val.userOrgs.isOwner;
}
- if (val.roles && val.roles.isAdmin) {
- res.isAdmin = val.roles.isAdmin;
+ if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) {
+ res.isAdmin = true;
}
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
res.isPrimaryOrg = val.orgs.isBillingOrg;
diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts
index 232cea266..d2124d22e 100644
--- a/server/routers/resource/createResource.ts
+++ b/server/routers/resource/createResource.ts
@@ -112,7 +112,7 @@ export async function createResource(
const { orgId } = parsedParams.data;
- if (req.user && !req.userOrgRoleId) {
+ if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -278,7 +278,7 @@ async function createHttpResource(
resourceId: newResource[0].resourceId
});
- if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
+ if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the resource
await trx.insert(userResources).values({
userId: req.user?.userId!,
@@ -371,7 +371,7 @@ async function createRawResource(
resourceId: newResource[0].resourceId
});
- if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
+ if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the resource
await trx.insert(userResources).values({
userId: req.user?.userId!,
diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts
index eb5f8a8d9..9afd6b4f3 100644
--- a/server/routers/resource/getUserResources.ts
+++ b/server/routers/resource/getUserResources.ts
@@ -5,6 +5,7 @@ import {
resources,
userResources,
roleResources,
+ userOrgRoles,
userOrgs,
resourcePassword,
resourcePincode,
@@ -32,22 +33,29 @@ export async function getUserResources(
);
}
- // First get the user's role in the organization
- const userOrgResult = await db
- .select({
- roleId: userOrgs.roleId
- })
+ // Check user is in organization and get their role IDs
+ const [userOrg] = await db
+ .select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
- if (userOrgResult.length === 0) {
+ if (!userOrg) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
);
}
- const userRoleId = userOrgResult[0].roleId;
+ const userRoleIds = await db
+ .select({ roleId: userOrgRoles.roleId })
+ .from(userOrgRoles)
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, orgId)
+ )
+ )
+ .then((rows) => rows.map((r) => r.roleId));
// Get resources accessible through direct assignment or role assignment
const directResourcesQuery = db
@@ -55,20 +63,28 @@ export async function getUserResources(
.from(userResources)
.where(eq(userResources.userId, userId));
- const roleResourcesQuery = db
- .select({ resourceId: roleResources.resourceId })
- .from(roleResources)
- .where(eq(roleResources.roleId, userRoleId));
+ const roleResourcesQuery =
+ userRoleIds.length > 0
+ ? db
+ .select({ resourceId: roleResources.resourceId })
+ .from(roleResources)
+ .where(inArray(roleResources.roleId, userRoleIds))
+ : Promise.resolve([]);
const directSiteResourcesQuery = db
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.where(eq(userSiteResources.userId, userId));
- const roleSiteResourcesQuery = db
- .select({ siteResourceId: roleSiteResources.siteResourceId })
- .from(roleSiteResources)
- .where(eq(roleSiteResources.roleId, userRoleId));
+ const roleSiteResourcesQuery =
+ userRoleIds.length > 0
+ ? db
+ .select({
+ siteResourceId: roleSiteResources.siteResourceId
+ })
+ .from(roleSiteResources)
+ .where(inArray(roleSiteResources.roleId, userRoleIds))
+ : Promise.resolve([]);
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
directResourcesQuery,
diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts
index a26a5df50..e6524a72e 100644
--- a/server/routers/resource/listResources.ts
+++ b/server/routers/resource/listResources.ts
@@ -276,7 +276,7 @@ export async function listResources(
.where(
or(
eq(userResources.userId, req.user!.userId),
- eq(roleResources.roleId, req.userOrgRoleId!)
+ inArray(roleResources.roleId, req.userOrgRoleIds!)
)
);
} else {
diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts
index 490fe91cc..24c26e654 100644
--- a/server/routers/role/deleteRole.ts
+++ b/server/routers/role/deleteRole.ts
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
-import { roles, userOrgs } from "@server/db";
+import { roles, userOrgRoles } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -114,11 +114,11 @@ export async function deleteRole(
}
await db.transaction(async (trx) => {
- // move all users from the userOrgs table with roleId to newRoleId
+ // move all users from userOrgRoles with roleId to newRoleId
await trx
- .update(userOrgs)
+ .update(userOrgRoles)
.set({ roleId: newRoleId })
- .where(eq(userOrgs.roleId, roleId));
+ .where(eq(userOrgRoles.roleId, roleId));
// delete the old role
await trx.delete(roles).where(eq(roles.roleId, roleId));
diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts
index ea4bc3e85..57e963e56 100644
--- a/server/routers/site/createSite.ts
+++ b/server/routers/site/createSite.ts
@@ -111,7 +111,7 @@ export async function createSite(
const { orgId } = parsedParams.data;
- if (req.user && !req.userOrgRoleId) {
+ if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -399,7 +399,7 @@ export async function createSite(
siteId: newSite.siteId
});
- if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
+ if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the site
trx.insert(userSites).values({
userId: req.user?.userId!,
diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts
index e4881b1ab..f2d460ff7 100644
--- a/server/routers/site/listSites.ts
+++ b/server/routers/site/listSites.ts
@@ -235,7 +235,7 @@ export async function listSites(
.where(
or(
eq(userSites.userId, req.user!.userId),
- eq(roleSites.roleId, req.userOrgRoleId!)
+ inArray(roleSites.roleId, req.userOrgRoleIds!)
)
);
} else {
diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts
index 388db4a31..30d3be7b9 100644
--- a/server/routers/user/acceptInvite.ts
+++ b/server/routers/user/acceptInvite.ts
@@ -165,9 +165,9 @@ export async function acceptInvite(
org,
{
userId: existingUser[0].userId,
- orgId: existingInvite.orgId,
- roleId: existingInvite.roleId
+ orgId: existingInvite.orgId
},
+ existingInvite.roleId,
trx
);
diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts
index 32eaa19d7..d41ad2051 100644
--- a/server/routers/user/addUserRole.ts
+++ b/server/routers/user/addUserRole.ts
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { clients, db, UserOrg } from "@server/db";
-import { userOrgs, roles } from "@server/db";
+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";
@@ -111,20 +111,23 @@ export async function addUserRole(
);
}
- let newUserRole: UserOrg | null = null;
+ let newUserRole: { userId: string; orgId: string; roleId: number } | null =
+ null;
await db.transaction(async (trx) => {
- [newUserRole] = await trx
- .update(userOrgs)
- .set({ roleId })
- .where(
- and(
- eq(userOrgs.userId, userId),
- eq(userOrgs.orgId, role.orgId)
- )
- )
+ const inserted = await trx
+ .insert(userOrgRoles)
+ .values({
+ userId,
+ orgId: role.orgId,
+ roleId
+ })
+ .onConflictDoNothing()
.returning();
- // get the client associated with this user in this org
+ if (inserted.length > 0) {
+ newUserRole = inserted[0];
+ }
+
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: newUserRole ?? { userId, orgId: role.orgId, roleId },
success: true,
error: false,
message: "Role added to user successfully",
diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts
index b39ea22e2..891836651 100644
--- a/server/routers/user/createOrgUser.ts
+++ b/server/routers/user/createOrgUser.ts
@@ -221,12 +221,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,
+ },
+ role.roleId,
+ trx
+ );
} else {
userId = generateId(15);
@@ -244,12 +248,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,
+ },
+ role.roleId,
+ trx
+ );
}
await calculateUserClientsForOrgs(userId, trx);
diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts
index f22a29d37..2cced3fc2 100644
--- a/server/routers/user/getOrgUser.ts
+++ b/server/routers/user/getOrgUser.ts
@@ -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";
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 @@ 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 @@ 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<
diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts
index 35c5c4a7c..2de44d8b1 100644
--- a/server/routers/user/index.ts
+++ b/server/routers/user/index.ts
@@ -2,6 +2,7 @@ export * from "./getUser";
export * from "./removeUserOrg";
export * from "./listUsers";
export * from "./addUserRole";
+export * from "./removeUserRole";
export * from "./inviteUser";
export * from "./acceptInvite";
export * from "./getOrgUser";
diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts
index 401dcf58b..aeced75b1 100644
--- a/server/routers/user/listUsers.ts
+++ b/server/routers/user/listUsers.ts
@@ -1,11 +1,11 @@
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, sql } from "drizzle-orm";
+import { sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -31,7 +31,7 @@ const listUsersSchema = z.strictObject({
});
async function queryUsers(orgId: string, limit: number, offset: number) {
- return await db
+ const rows = await db
.select({
id: users.userId,
email: users.email,
@@ -41,8 +41,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
username: users.username,
name: users.name,
type: users.type,
- roleId: userOrgs.roleId,
- roleName: roles.name,
isOwner: userOrgs.isOwner,
idpName: idp.name,
idpId: users.idpId,
@@ -52,12 +50,39 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
})
.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))
.where(eq(userOrgs.orgId, orgId))
.limit(limit)
.offset(offset);
+
+ const roleRows = await db
+ .select({
+ userId: userOrgRoles.userId,
+ roleId: userOrgRoles.roleId,
+ roleName: roles.name
+ })
+ .from(userOrgRoles)
+ .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
+ .where(eq(userOrgRoles.orgId, orgId));
+
+ 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);
+ }
+
+ return rows.map((row) => {
+ const userRoles = rolesByUser.get(row.id) ?? [];
+ return {
+ ...row,
+ roles: userRoles
+ };
+ });
}
export type ListUsersResponse = {
diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts
index 144108e11..3b991ca56 100644
--- a/server/routers/user/myDevice.ts
+++ b/server/routers/user/myDevice.ts
@@ -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";
@@ -84,16 +84,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(res, {
data: {
user,
diff --git a/server/routers/user/removeUserRole.ts b/server/routers/user/removeUserRole.ts
new file mode 100644
index 000000000..8d353fea3
--- /dev/null
+++ b/server/routers/user/removeUserRole.ts
@@ -0,0 +1,157 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import { userOrgRoles, userOrgs, roles, clients } 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 removeUserRoleParamsSchema = z.strictObject({
+ userId: z.string(),
+ roleId: z.string().transform(stoi).pipe(z.number())
+});
+
+registry.registerPath({
+ method: "delete",
+ path: "/role/{roleId}/remove/{userId}",
+ description: "Remove a role from a user. User must have at least one role left in the org.",
+ tags: [OpenAPITags.Role, OpenAPITags.User],
+ request: {
+ params: removeUserRoleParamsSchema
+ },
+ responses: {}
+});
+
+export async function removeUserRole(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ try {
+ const parsedParams = removeUserRoleParamsSchema.safeParse(req.params);
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const { userId, roleId } = parsedParams.data;
+
+ if (req.user && !req.userOrg) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "You do not have access to this organization"
+ )
+ );
+ }
+
+ const [role] = await db
+ .select()
+ .from(roles)
+ .where(eq(roles.roleId, roleId))
+ .limit(1);
+
+ if (!role) {
+ return next(
+ createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
+ );
+ }
+
+ const [existingUser] = await db
+ .select()
+ .from(userOrgs)
+ .where(
+ and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))
+ )
+ .limit(1);
+
+ if (!existingUser) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "User not found or does not belong to the specified organization"
+ )
+ );
+ }
+
+ if (existingUser.isOwner) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "Cannot change the roles of the owner of the organization"
+ )
+ );
+ }
+
+ const remainingRoles = await db
+ .select({ roleId: userOrgRoles.roleId })
+ .from(userOrgRoles)
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, role.orgId)
+ )
+ );
+
+ if (remainingRoles.length <= 1) {
+ const hasThisRole = remainingRoles.some((r) => r.roleId === roleId);
+ if (hasThisRole) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "User must have at least one role in the organization. Remove the last role is not allowed."
+ )
+ );
+ }
+ }
+
+ await db.transaction(async (trx) => {
+ await trx
+ .delete(userOrgRoles)
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, role.orgId),
+ eq(userOrgRoles.roleId, roleId)
+ )
+ );
+
+ const orgClients = await trx
+ .select()
+ .from(clients)
+ .where(
+ and(
+ eq(clients.userId, userId),
+ eq(clients.orgId, role.orgId)
+ )
+ );
+
+ for (const orgClient of orgClients) {
+ await rebuildClientAssociationsFromClient(orgClient, trx);
+ }
+ });
+
+ return response(res, {
+ data: { userId, orgId: role.orgId, roleId },
+ success: true,
+ error: false,
+ message: "Role removed from user successfully",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+ );
+ }
+}
diff --git a/server/types/Auth.ts b/server/types/Auth.ts
index 8e222987c..398c02406 100644
--- a/server/types/Auth.ts
+++ b/server/types/Auth.ts
@@ -5,5 +5,5 @@ import { Session } from "@server/db";
export interface AuthenticatedRequest extends Request {
user: User;
session: Session;
- userOrgRoleId?: number;
+ userOrgRoleIds?: number[];
}
diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
index 6313d512a..7a1dab309 100644
--- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
+++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
@@ -8,7 +8,6 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
-import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
@@ -19,7 +18,6 @@ import {
import { Checkbox } from "@app/components/ui/checkbox";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
-import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -44,6 +42,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
+import { Badge } from "@app/components/ui/badge";
+
+type UserRole = { roleId: number; name: string };
export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext();
@@ -54,12 +55,12 @@ export default function AccessControlsPage() {
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
+ const [userRoles, setUserRoles] = useState([]);
const t = useTranslations();
const formSchema = z.object({
username: z.string(),
- roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }),
autoProvisioned: z.boolean()
});
@@ -67,11 +68,17 @@ export default function AccessControlsPage() {
resolver: zodResolver(formSchema),
defaultValues: {
username: user.username!,
- roleId: user.roleId?.toString(),
autoProvisioned: user.autoProvisioned || false
}
});
+ const currentRoleIds = user.roleIds ?? [];
+ const currentRoles: UserRole[] = user.roles ?? [];
+
+ useEffect(() => {
+ setUserRoles(currentRoles);
+ }, [user.userId, currentRoleIds.join(",")]);
+
useEffect(() => {
async function fetchRoles() {
const res = await api
@@ -94,32 +101,20 @@ export default function AccessControlsPage() {
}
fetchRoles();
-
- form.setValue("roleId", user.roleId.toString());
form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []);
- async function onSubmit(values: z.infer) {
+ async function handleAddRole(roleId: number) {
setLoading(true);
-
try {
- // Execute both API calls simultaneously
- const [roleRes, userRes] = await Promise.all([
- api.post>(
- `/role/${values.roleId}/add/${user.userId}`
- ),
- api.post(`/org/${orgId}/user/${user.userId}`, {
- autoProvisioned: values.autoProvisioned
- })
- ]);
-
- if (roleRes.status === 200 && userRes.status === 200) {
- toast({
- variant: "default",
- title: t("userSaved"),
- description: t("userSavedDescription")
- });
- }
+ await api.post(`/role/${roleId}/add/${user.userId}`);
+ toast({
+ variant: "default",
+ title: t("userSaved"),
+ description: t("userSavedDescription")
+ });
+ const role = roles.find((r) => r.roleId === roleId);
+ if (role) setUserRoles((prev) => [...prev, role]);
} catch (e) {
toast({
variant: "destructive",
@@ -130,10 +125,61 @@ export default function AccessControlsPage() {
)
});
}
-
setLoading(false);
}
+ async function handleRemoveRole(roleId: number) {
+ setLoading(true);
+ try {
+ await api.delete(`/role/${roleId}/remove/${user.userId}`);
+ toast({
+ variant: "default",
+ title: t("userSaved"),
+ description: t("userSavedDescription")
+ });
+ setUserRoles((prev) => prev.filter((r) => r.roleId !== roleId));
+ } catch (e) {
+ toast({
+ variant: "destructive",
+ title: t("accessRoleErrorAdd"),
+ description: formatAxiosError(
+ e,
+ t("accessRoleErrorAddDescription")
+ )
+ });
+ }
+ setLoading(false);
+ }
+
+ async function onSubmit(values: z.infer) {
+ setLoading(true);
+ try {
+ await api.post(`/org/${orgId}/user/${user.userId}`, {
+ autoProvisioned: values.autoProvisioned
+ });
+ toast({
+ variant: "default",
+ title: t("userSaved"),
+ description: t("userSavedDescription")
+ });
+ } catch (e) {
+ toast({
+ variant: "destructive",
+ title: t("accessRoleErrorAdd"),
+ description: formatAxiosError(
+ e,
+ t("accessRoleErrorAddDescription")
+ )
+ });
+ }
+ setLoading(false);
+ }
+
+ const availableRolesToAdd = roles.filter(
+ (r) => !userRoles.some((ur) => ur.roleId === r.roleId)
+ );
+ const canRemoveRole = userRoles.length > 1;
+
return (
@@ -154,7 +200,6 @@ export default function AccessControlsPage() {
className="space-y-4"
id="access-controls-form"
>
- {/* IDP Type Display */}
{user.type !== UserType.Internal &&
user.idpType && (
@@ -171,49 +216,72 @@ export default function AccessControlsPage() {
)}
- (
-
- {t("role")}
+
+ {t("role")}
+
+ {userRoles.map((r) => (
+
+ {r.name}
+ {canRemoveRole && (
+
+ )}
+
+ ))}
+ {availableRolesToAdd.length > 0 && (
-
-
+ )}
+
+ {userRoles.length === 0 && (
+
+ {t("accessRoleSelectPlease")}
+
)}
- />
+
{user.idpAutoProvision && (
- {t("autoProvisioned")}
+ {t(
+ "autoProvisioned"
+ )}
{t(
diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx
index c10363734..c64ee6396 100644
--- a/src/app/[orgId]/settings/access/users/page.tsx
+++ b/src/app/[orgId]/settings/access/users/page.tsx
@@ -88,7 +88,9 @@ export default async function UsersPage(props: UsersPageProps) {
status: t("userConfirmed"),
role: user.isOwner
? t("accessRoleOwner")
- : user.roleName || t("accessRoleMember"),
+ : user.roles?.length
+ ? user.roles.map((r) => r.roleName).join(", ")
+ : t("accessRoleMember"),
isOwner: user.isOwner || false
};
});
From e13a0769396a6d8cf8293cc96b8a3606c953950f Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 26 Mar 2026 16:37:31 -0700
Subject: [PATCH 02/19] ui improvements
---
messages/bg-BG.json | 1 +
messages/cs-CZ.json | 1 +
messages/de-DE.json | 1 +
messages/en-US.json | 1 +
messages/es-ES.json | 1 +
messages/fr-FR.json | 1 +
messages/it-IT.json | 1 +
messages/ko-KR.json | 1 +
messages/nb-NO.json | 1 +
messages/nl-NL.json | 1 +
messages/pl-PL.json | 1 +
messages/pt-PT.json | 1 +
messages/ru-RU.json | 1 +
messages/tr-TR.json | 1 +
messages/zh-CN.json | 1 +
messages/zh-TW.json | 1 +
server/auth/actions.ts | 5 +-
server/db/queries/verifySessionQueries.ts | 42 ---
server/middlewares/index.ts | 1 +
server/middlewares/integration/index.ts | 1 +
.../verifyApiKeyCanSetUserOrgRoles.ts | 74 ++++++
.../verifyUserCanSetUserOrgRoles.ts | 54 ++++
server/routers/external.ts | 11 +
server/routers/integration.ts | 11 +
server/routers/user/index.ts | 1 +
server/routers/user/listUsers.ts | 30 ++-
server/routers/user/setUserOrgRoles.ts | 151 +++++++++++
.../users/[userId]/access-controls/page.tsx | 239 ++++++++----------
.../[orgId]/settings/access/users/page.tsx | 13 +-
.../proxy/[niceId]/authentication/page.tsx | 9 +-
src/components/PermissionsSelectBox.tsx | 3 +-
src/components/UsersTable.tsx | 75 +++++-
src/components/ui/badge.tsx | 2 +-
33 files changed, 533 insertions(+), 205 deletions(-)
create mode 100644 server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts
create mode 100644 server/middlewares/verifyUserCanSetUserOrgRoles.ts
create mode 100644 server/routers/user/setUserOrgRoles.ts
diff --git a/messages/bg-BG.json b/messages/bg-BG.json
index 0b96141e9..10b83f38d 100644
--- a/messages/bg-BG.json
+++ b/messages/bg-BG.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Изтрийте потребител",
"actionListUsers": "Изброяване на потребители",
"actionAddUserRole": "Добавяне на роля на потребител",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Генериране на токен за достъп",
"actionDeleteAccessToken": "Изтриване на токен за достъп",
"actionListAccessTokens": "Изброяване на токени за достъп",
diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json
index cb5372b36..ea12fe535 100644
--- a/messages/cs-CZ.json
+++ b/messages/cs-CZ.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Odstranit uživatele",
"actionListUsers": "Seznam uživatelů",
"actionAddUserRole": "Přidat uživatelskou roli",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generovat přístupový token",
"actionDeleteAccessToken": "Odstranit přístupový token",
"actionListAccessTokens": "Seznam přístupových tokenů",
diff --git a/messages/de-DE.json b/messages/de-DE.json
index 150a8597e..cef7223fb 100644
--- a/messages/de-DE.json
+++ b/messages/de-DE.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Benutzer entfernen",
"actionListUsers": "Benutzer auflisten",
"actionAddUserRole": "Benutzerrolle hinzufügen",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Zugriffstoken generieren",
"actionDeleteAccessToken": "Zugriffstoken löschen",
"actionListAccessTokens": "Zugriffstoken auflisten",
diff --git a/messages/en-US.json b/messages/en-US.json
index 895ee1332..cdfd57110 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1150,6 +1150,7 @@
"actionRemoveUser": "Remove User",
"actionListUsers": "List Users",
"actionAddUserRole": "Add User Role",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generate Access Token",
"actionDeleteAccessToken": "Delete Access Token",
"actionListAccessTokens": "List Access Tokens",
diff --git a/messages/es-ES.json b/messages/es-ES.json
index e33a85ace..2fc52b885 100644
--- a/messages/es-ES.json
+++ b/messages/es-ES.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Eliminar usuario",
"actionListUsers": "Listar usuarios",
"actionAddUserRole": "Añadir rol de usuario",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generar token de acceso",
"actionDeleteAccessToken": "Eliminar token de acceso",
"actionListAccessTokens": "Lista de Tokens de Acceso",
diff --git a/messages/fr-FR.json b/messages/fr-FR.json
index ec3dbffb8..2b3368a07 100644
--- a/messages/fr-FR.json
+++ b/messages/fr-FR.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Supprimer un utilisateur",
"actionListUsers": "Lister les utilisateurs",
"actionAddUserRole": "Ajouter un rôle utilisateur",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Générer un jeton d'accès",
"actionDeleteAccessToken": "Supprimer un jeton d'accès",
"actionListAccessTokens": "Lister les jetons d'accès",
diff --git a/messages/it-IT.json b/messages/it-IT.json
index adab7879a..6891cd290 100644
--- a/messages/it-IT.json
+++ b/messages/it-IT.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Rimuovi Utente",
"actionListUsers": "Elenca Utenti",
"actionAddUserRole": "Aggiungi Ruolo Utente",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Genera Token di Accesso",
"actionDeleteAccessToken": "Elimina Token di Accesso",
"actionListAccessTokens": "Elenca Token di Accesso",
diff --git a/messages/ko-KR.json b/messages/ko-KR.json
index 59f464305..02915abd7 100644
--- a/messages/ko-KR.json
+++ b/messages/ko-KR.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "사용자 제거",
"actionListUsers": "사용자 목록",
"actionAddUserRole": "사용자 역할 추가",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "액세스 토큰 생성",
"actionDeleteAccessToken": "액세스 토큰 삭제",
"actionListAccessTokens": "액세스 토큰 목록",
diff --git a/messages/nb-NO.json b/messages/nb-NO.json
index e8a9fa9a3..50ec9a717 100644
--- a/messages/nb-NO.json
+++ b/messages/nb-NO.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Fjern bruker",
"actionListUsers": "List opp brukere",
"actionAddUserRole": "Legg til brukerrolle",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generer tilgangstoken",
"actionDeleteAccessToken": "Slett tilgangstoken",
"actionListAccessTokens": "List opp tilgangstokener",
diff --git a/messages/nl-NL.json b/messages/nl-NL.json
index 32580cc45..f0eaff3fd 100644
--- a/messages/nl-NL.json
+++ b/messages/nl-NL.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Gebruiker verwijderen",
"actionListUsers": "Gebruikers weergeven",
"actionAddUserRole": "Gebruikersrol toevoegen",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Genereer Toegangstoken",
"actionDeleteAccessToken": "Verwijder toegangstoken",
"actionListAccessTokens": "Lijst toegangstokens",
diff --git a/messages/pl-PL.json b/messages/pl-PL.json
index ba0587b94..998fcc880 100644
--- a/messages/pl-PL.json
+++ b/messages/pl-PL.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Usuń użytkownika",
"actionListUsers": "Lista użytkowników",
"actionAddUserRole": "Dodaj rolę użytkownika",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Wygeneruj token dostępu",
"actionDeleteAccessToken": "Usuń token dostępu",
"actionListAccessTokens": "Lista tokenów dostępu",
diff --git a/messages/pt-PT.json b/messages/pt-PT.json
index 3ce98fff6..b121f4b16 100644
--- a/messages/pt-PT.json
+++ b/messages/pt-PT.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Remover Utilizador",
"actionListUsers": "Listar Utilizadores",
"actionAddUserRole": "Adicionar Função ao Utilizador",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Gerar Token de Acesso",
"actionDeleteAccessToken": "Eliminar Token de Acesso",
"actionListAccessTokens": "Listar Tokens de Acesso",
diff --git a/messages/ru-RU.json b/messages/ru-RU.json
index 12043d8a2..0d42245e4 100644
--- a/messages/ru-RU.json
+++ b/messages/ru-RU.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Удалить пользователя",
"actionListUsers": "Список пользователей",
"actionAddUserRole": "Добавить роль пользователя",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Сгенерировать токен доступа",
"actionDeleteAccessToken": "Удалить токен доступа",
"actionListAccessTokens": "Список токенов доступа",
diff --git a/messages/tr-TR.json b/messages/tr-TR.json
index 362f891fb..2bfed7fb3 100644
--- a/messages/tr-TR.json
+++ b/messages/tr-TR.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Kullanıcıyı Kaldır",
"actionListUsers": "Kullanıcıları Listele",
"actionAddUserRole": "Kullanıcı Rolü Ekle",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Erişim Jetonu Oluştur",
"actionDeleteAccessToken": "Erişim Jetonunu Sil",
"actionListAccessTokens": "Erişim Jetonlarını Listele",
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index a7f2682fa..f297d1ea9 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -1148,6 +1148,7 @@
"actionRemoveUser": "删除用户",
"actionListUsers": "列出用户",
"actionAddUserRole": "添加用户角色",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "生成访问令牌",
"actionDeleteAccessToken": "删除访问令牌",
"actionListAccessTokens": "访问令牌",
diff --git a/messages/zh-TW.json b/messages/zh-TW.json
index 1ae6a5156..8b9d05f53 100644
--- a/messages/zh-TW.json
+++ b/messages/zh-TW.json
@@ -1091,6 +1091,7 @@
"actionRemoveUser": "刪除用戶",
"actionListUsers": "列出用戶",
"actionAddUserRole": "添加用戶角色",
+ "actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "生成訪問令牌",
"actionDeleteAccessToken": "刪除訪問令牌",
"actionListAccessTokens": "訪問令牌",
diff --git a/server/auth/actions.ts b/server/auth/actions.ts
index 6a5ed15dc..ae5136659 100644
--- a/server/auth/actions.ts
+++ b/server/auth/actions.ts
@@ -4,6 +4,7 @@ import { userActions, roleActions } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
+import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export enum ActionsEnum {
createOrgUser = "createOrgUser",
@@ -54,6 +55,7 @@ export enum ActionsEnum {
// listRoleActions = "listRoleActions",
addUserRole = "addUserRole",
removeUserRole = "removeUserRole",
+ setUserOrgRoles = "setUserOrgRoles",
// addUserSite = "addUserSite",
// addUserAction = "addUserAction",
// removeUserAction = "removeUserAction",
@@ -158,9 +160,6 @@ export async function checkUserActionPermission(
let userOrgRoleIds = req.userOrgRoleIds;
if (userOrgRoleIds === undefined) {
- const { getUserOrgRoleIds } = await import(
- "@server/lib/userOrgRoles"
- );
userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!);
if (userOrgRoleIds.length === 0) {
throw createHttpError(
diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts
index 469df590d..66f968b02 100644
--- a/server/db/queries/verifySessionQueries.ts
+++ b/server/db/queries/verifySessionQueries.ts
@@ -104,48 +104,6 @@ export async function getUserSessionWithUser(
};
}
-/**
- * Get user organization role (single role; prefer getUserOrgRoleIds + roles for multi-role).
- * @deprecated Use userOrgRoles table and getUserOrgRoleIds for multi-role support.
- */
-export async function getUserOrgRole(userId: string, orgId: string) {
- const userOrg = await db
- .select({
- userId: userOrgs.userId,
- orgId: userOrgs.orgId,
- isOwner: userOrgs.isOwner,
- autoProvisioned: userOrgs.autoProvisioned
- })
- .from(userOrgs)
- .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
- .limit(1);
-
- if (userOrg.length === 0) return null;
-
- const [firstRole] = await db
- .select({
- roleId: userOrgRoles.roleId,
- roleName: roles.name
- })
- .from(userOrgRoles)
- .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
- .where(
- and(
- eq(userOrgRoles.userId, userId),
- eq(userOrgRoles.orgId, orgId)
- )
- )
- .limit(1);
-
- return firstRole
- ? {
- ...userOrg[0],
- roleId: firstRole.roleId,
- roleName: firstRole.roleName
- }
- : { ...userOrg[0], roleId: null, roleName: null };
-}
-
/**
* Get role name by role ID (for display).
*/
diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts
index 6437c90e2..435ccdb23 100644
--- a/server/middlewares/index.ts
+++ b/server/middlewares/index.ts
@@ -17,6 +17,7 @@ export * from "./verifyAccessTokenAccess";
export * from "./requestTimeout";
export * from "./verifyClientAccess";
export * from "./verifyUserHasAction";
+export * from "./verifyUserCanSetUserOrgRoles";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser";
export * from "./verifyIsLoggedInUser";
diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts
index df186c1c8..8a213c6d2 100644
--- a/server/middlewares/integration/index.ts
+++ b/server/middlewares/integration/index.ts
@@ -1,6 +1,7 @@
export * from "./verifyApiKey";
export * from "./verifyApiKeyOrgAccess";
export * from "./verifyApiKeyHasAction";
+export * from "./verifyApiKeyCanSetUserOrgRoles";
export * from "./verifyApiKeySiteAccess";
export * from "./verifyApiKeyResourceAccess";
export * from "./verifyApiKeyTargetAccess";
diff --git a/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts b/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts
new file mode 100644
index 000000000..894665095
--- /dev/null
+++ b/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts
@@ -0,0 +1,74 @@
+import { Request, Response, NextFunction } from "express";
+import createHttpError from "http-errors";
+import HttpCode from "@server/types/HttpCode";
+import logger from "@server/logger";
+import { ActionsEnum } from "@server/auth/actions";
+import { db } from "@server/db";
+import { apiKeyActions } from "@server/db";
+import { and, eq } from "drizzle-orm";
+
+async function apiKeyHasAction(apiKeyId: string, actionId: ActionsEnum) {
+ const [row] = await db
+ .select()
+ .from(apiKeyActions)
+ .where(
+ and(
+ eq(apiKeyActions.apiKeyId, apiKeyId),
+ eq(apiKeyActions.actionId, actionId)
+ )
+ );
+ return !!row;
+}
+
+/**
+ * Allows setUserOrgRoles on the key, or both addUserRole and removeUserRole.
+ */
+export function verifyApiKeyCanSetUserOrgRoles() {
+ return async function (
+ req: Request,
+ res: Response,
+ next: NextFunction
+ ): Promise {
+ try {
+ if (!req.apiKey) {
+ return next(
+ createHttpError(
+ HttpCode.UNAUTHORIZED,
+ "API Key not authenticated"
+ )
+ );
+ }
+
+ const keyId = req.apiKey.apiKeyId;
+
+ if (await apiKeyHasAction(keyId, ActionsEnum.setUserOrgRoles)) {
+ return next();
+ }
+
+ const hasAdd = await apiKeyHasAction(keyId, ActionsEnum.addUserRole);
+ const hasRemove = await apiKeyHasAction(
+ keyId,
+ ActionsEnum.removeUserRole
+ );
+
+ if (hasAdd && hasRemove) {
+ return next();
+ }
+
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "Key does not have permission perform this action"
+ )
+ );
+ } catch (error) {
+ logger.error("Error verifying API key set user org roles:", error);
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Error verifying key action access"
+ )
+ );
+ }
+ };
+}
diff --git a/server/middlewares/verifyUserCanSetUserOrgRoles.ts b/server/middlewares/verifyUserCanSetUserOrgRoles.ts
new file mode 100644
index 000000000..1a7554ab3
--- /dev/null
+++ b/server/middlewares/verifyUserCanSetUserOrgRoles.ts
@@ -0,0 +1,54 @@
+import { Request, Response, NextFunction } from "express";
+import createHttpError from "http-errors";
+import HttpCode from "@server/types/HttpCode";
+import logger from "@server/logger";
+import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
+
+/**
+ * Allows the new setUserOrgRoles action, or legacy permission pair addUserRole + removeUserRole.
+ */
+export function verifyUserCanSetUserOrgRoles() {
+ return async function (
+ req: Request,
+ res: Response,
+ next: NextFunction
+ ): Promise {
+ try {
+ const canSet = await checkUserActionPermission(
+ ActionsEnum.setUserOrgRoles,
+ req
+ );
+ if (canSet) {
+ return next();
+ }
+
+ const canAdd = await checkUserActionPermission(
+ ActionsEnum.addUserRole,
+ req
+ );
+ const canRemove = await checkUserActionPermission(
+ ActionsEnum.removeUserRole,
+ req
+ );
+
+ if (canAdd && canRemove) {
+ return next();
+ }
+
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "User does not have permission perform this action"
+ )
+ );
+ } catch (error) {
+ logger.error("Error verifying set user org roles access:", error);
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Error verifying role access"
+ )
+ );
+ }
+ };
+}
diff --git a/server/routers/external.ts b/server/routers/external.ts
index bae7bb4db..bb331dc69 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -39,6 +39,7 @@ import {
verifyApiKeyAccess,
verifyDomainAccess,
verifyUserHasAction,
+ verifyUserCanSetUserOrgRoles,
verifyUserIsOrgOwner,
verifySiteResourceAccess,
verifyOlmAccess,
@@ -837,6 +838,16 @@ authenticated.post(
user.updateOrgUser
);
+authenticated.post(
+ "/org/:orgId/user/:userId/roles",
+ verifyOrgAccess,
+ verifyUserAccess,
+ verifyLimits,
+ verifyUserCanSetUserOrgRoles(),
+ logActionAudit(ActionsEnum.setUserOrgRoles),
+ user.setUserOrgRoles
+);
+
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess);
diff --git a/server/routers/integration.ts b/server/routers/integration.ts
index e77f82c83..32c06078b 100644
--- a/server/routers/integration.ts
+++ b/server/routers/integration.ts
@@ -16,6 +16,7 @@ import {
verifyApiKey,
verifyApiKeyOrgAccess,
verifyApiKeyHasAction,
+ verifyApiKeyCanSetUserOrgRoles,
verifyApiKeySiteAccess,
verifyApiKeyResourceAccess,
verifyApiKeyTargetAccess,
@@ -814,6 +815,16 @@ authenticated.post(
user.updateOrgUser
);
+authenticated.post(
+ "/org/:orgId/user/:userId/roles",
+ verifyApiKeyOrgAccess,
+ verifyApiKeyUserAccess,
+ verifyLimits,
+ verifyApiKeyCanSetUserOrgRoles(),
+ logActionAudit(ActionsEnum.setUserOrgRoles),
+ user.setUserOrgRoles
+);
+
authenticated.delete(
"/org/:orgId/user/:userId",
verifyApiKeyOrgAccess,
diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts
index a300e0092..0884e8a2b 100644
--- a/server/routers/user/index.ts
+++ b/server/routers/user/index.ts
@@ -3,6 +3,7 @@ export * from "./removeUserOrg";
export * from "./listUsers";
export * from "./addUserRole";
export * from "./removeUserRole";
+export * from "./setUserOrgRoles";
export * from "./inviteUser";
export * from "./acceptInvite";
export * from "./getOrgUser";
diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts
index 37567f24e..fe7f6b250 100644
--- a/server/routers/user/listUsers.ts
+++ b/server/routers/user/listUsers.ts
@@ -5,11 +5,10 @@ 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 { sql } from "drizzle-orm";
+import { and, eq, inArray, 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";
const listUsersParamsSchema = z.strictObject({
orgId: z.string()
@@ -56,15 +55,24 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
.limit(limit)
.offset(offset);
- const roleRows = await db
- .select({
- userId: userOrgRoles.userId,
- roleId: userOrgRoles.roleId,
- roleName: roles.name
- })
- .from(userOrgRoles)
- .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
- .where(eq(userOrgRoles.orgId, orgId));
+ const userIds = rows.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,
diff --git a/server/routers/user/setUserOrgRoles.ts b/server/routers/user/setUserOrgRoles.ts
new file mode 100644
index 000000000..525c91729
--- /dev/null
+++ b/server/routers/user/setUserOrgRoles.ts
@@ -0,0 +1,151 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { clients, db } from "@server/db";
+import { userOrgRoles, userOrgs, roles } 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";
+import logger from "@server/logger";
+import { fromError } from "zod-validation-error";
+import { OpenAPITags, registry } from "@server/openApi";
+import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
+
+const setUserOrgRolesParamsSchema = z.strictObject({
+ orgId: z.string(),
+ userId: z.string()
+});
+
+const setUserOrgRolesBodySchema = z.strictObject({
+ roleIds: z.array(z.int().positive()).min(1)
+});
+
+export async function setUserOrgRoles(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ try {
+ const parsedParams = setUserOrgRolesParamsSchema.safeParse(req.params);
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const parsedBody = setUserOrgRolesBodySchema.safeParse(req.body);
+ if (!parsedBody.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedBody.error).toString()
+ )
+ );
+ }
+
+ const { orgId, userId } = parsedParams.data;
+ const { roleIds } = parsedBody.data;
+
+ if (req.user && !req.userOrg) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "You do not have access to this organization"
+ )
+ );
+ }
+
+ const uniqueRoleIds = [...new Set(roleIds)];
+
+ const [existingUser] = await db
+ .select()
+ .from(userOrgs)
+ .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
+ .limit(1);
+
+ if (!existingUser) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "User not found in this organization"
+ )
+ );
+ }
+
+ if (existingUser.isOwner) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "Cannot change the roles of the owner of the organization"
+ )
+ );
+ }
+
+ 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,
+ "One or more role IDs are invalid for this organization"
+ )
+ );
+ }
+
+ await db.transaction(async (trx) => {
+ await trx
+ .delete(userOrgRoles)
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, orgId)
+ )
+ );
+
+ if (uniqueRoleIds.length > 0) {
+ await trx.insert(userOrgRoles).values(
+ uniqueRoleIds.map((roleId) => ({
+ userId,
+ orgId,
+ roleId
+ }))
+ );
+ }
+
+ const orgClients = await trx
+ .select()
+ .from(clients)
+ .where(
+ and(eq(clients.userId, userId), eq(clients.orgId, orgId))
+ );
+
+ for (const orgClient of orgClients) {
+ await rebuildClientAssociationsFromClient(orgClient, trx);
+ }
+ });
+
+ return response(res, {
+ data: { userId, orgId, roleIds: uniqueRoleIds },
+ success: true,
+ error: false,
+ message: "User roles set successfully",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+ );
+ }
+}
diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
index 7a1dab309..6dcdf16fb 100644
--- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
+++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
@@ -3,23 +3,18 @@
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue
-} from "@app/components/ui/select";
import { Checkbox } from "@app/components/ui/checkbox";
+import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role";
@@ -42,12 +37,20 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
-import { Badge } from "@app/components/ui/badge";
-type UserRole = { roleId: number; name: string };
+const accessControlsFormSchema = z.object({
+ username: z.string(),
+ autoProvisioned: z.boolean(),
+ roles: z.array(
+ z.object({
+ id: z.string(),
+ text: z.string()
+ })
+ )
+});
export default function AccessControlsPage() {
- const { orgUser: user } = userOrgUserContext();
+ const { orgUser: user, updateOrgUser } = userOrgUserContext();
const api = createApiClient(useEnvContext());
@@ -55,28 +58,34 @@ export default function AccessControlsPage() {
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
- const [userRoles, setUserRoles] = useState([]);
+ const [activeRoleTagIndex, setActiveRoleTagIndex] = useState(
+ null
+ );
const t = useTranslations();
- const formSchema = z.object({
- username: z.string(),
- autoProvisioned: z.boolean()
- });
-
const form = useForm({
- resolver: zodResolver(formSchema),
+ resolver: zodResolver(accessControlsFormSchema),
defaultValues: {
username: user.username!,
- autoProvisioned: user.autoProvisioned || false
+ autoProvisioned: user.autoProvisioned || false,
+ roles: (user.roles ?? []).map((r) => ({
+ id: r.roleId.toString(),
+ text: r.name
+ }))
}
});
const currentRoleIds = user.roleIds ?? [];
- const currentRoles: UserRole[] = user.roles ?? [];
useEffect(() => {
- setUserRoles(currentRoles);
+ form.setValue(
+ "roles",
+ (user.roles ?? []).map((r) => ({
+ id: r.roleId.toString(),
+ text: r.name
+ }))
+ );
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => {
@@ -104,59 +113,67 @@ export default function AccessControlsPage() {
form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []);
- async function handleAddRole(roleId: number) {
- setLoading(true);
- try {
- await api.post(`/role/${roleId}/add/${user.userId}`);
- toast({
- variant: "default",
- title: t("userSaved"),
- description: t("userSavedDescription")
- });
- const role = roles.find((r) => r.roleId === roleId);
- if (role) setUserRoles((prev) => [...prev, role]);
- } catch (e) {
+ const allRoleOptions = useMemo(
+ () =>
+ roles
+ .map((role) => ({
+ id: role.roleId.toString(),
+ text: role.name
+ }))
+ .filter((role) => role.text !== "Admin"),
+ [roles]
+ );
+
+ function setRoleTags(
+ updater: Tag[] | ((prev: Tag[]) => Tag[])
+ ) {
+ const prev = form.getValues("roles");
+ const next = typeof updater === "function" ? updater(prev) : updater;
+
+ if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
- description: formatAxiosError(
- e,
- t("accessRoleErrorAddDescription")
- )
+ description: t("accessRoleSelectPlease")
});
+ return;
}
- setLoading(false);
+
+ form.setValue("roles", next, { shouldDirty: true });
}
- async function handleRemoveRole(roleId: number) {
- setLoading(true);
- try {
- await api.delete(`/role/${roleId}/remove/${user.userId}`);
- toast({
- variant: "default",
- title: t("userSaved"),
- description: t("userSavedDescription")
- });
- setUserRoles((prev) => prev.filter((r) => r.roleId !== roleId));
- } catch (e) {
+ async function onSubmit(values: z.infer) {
+ if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
- description: formatAxiosError(
- e,
- t("accessRoleErrorAddDescription")
- )
+ description: t("accessRoleSelectPlease")
});
+ return;
}
- setLoading(false);
- }
- async function onSubmit(values: z.infer) {
setLoading(true);
try {
- await api.post(`/org/${orgId}/user/${user.userId}`, {
+ const roleIds = values.roles.map((r) => parseInt(r.id, 10));
+
+ await Promise.all([
+ api.post(`/org/${orgId}/user/${user.userId}/roles`, {
+ roleIds
+ }),
+ api.post(`/org/${orgId}/user/${user.userId}`, {
+ autoProvisioned: values.autoProvisioned
+ })
+ ]);
+
+ updateOrgUser({
+ roleIds,
+ roles: values.roles.map((r) => ({
+ roleId: parseInt(r.id, 10),
+ name: r.text
+ })),
autoProvisioned: values.autoProvisioned
});
+
toast({
variant: "default",
title: t("userSaved"),
@@ -175,11 +192,6 @@ export default function AccessControlsPage() {
setLoading(false);
}
- const availableRolesToAdd = roles.filter(
- (r) => !userRoles.some((ur) => ur.roleId === r.roleId)
- );
- const canRemoveRole = userRoles.length > 1;
-
return (
@@ -216,72 +228,43 @@ export default function AccessControlsPage() {
)}
-
- {t("role")}
-
- {userRoles.map((r) => (
-
- {r.name}
- {canRemoveRole && (
-
- )}
-
- ))}
- {availableRolesToAdd.length > 0 && (
-
- )}
-
- {userRoles.length === 0 && (
-
- {t("accessRoleSelectPlease")}
-
+ size="sm"
+ tags={field.value}
+ setTags={setRoleTags}
+ enableAutocomplete={true}
+ autocompleteOptions={
+ allRoleOptions
+ }
+ allowDuplicates={false}
+ restrictTagsToAutocompleteOptions={
+ true
+ }
+ sortTags={true}
+ disabled={loading}
+ />
+
+
+
)}
-
+ />
{user.idpAutoProvision && (
- {t(
- "autoProvisioned"
- )}
+ {t("autoProvisioned")}
{t(
diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx
index c64ee6396..812ac2b64 100644
--- a/src/app/[orgId]/settings/access/users/page.tsx
+++ b/src/app/[orgId]/settings/access/users/page.tsx
@@ -86,11 +86,14 @@ export default async function UsersPage(props: UsersPageProps) {
idpId: user.idpId,
idpName: user.idpName || t("idpNameInternal"),
status: t("userConfirmed"),
- role: user.isOwner
- ? t("accessRoleOwner")
- : user.roles?.length
- ? user.roles.map((r) => r.roleName).join(", ")
- : t("accessRoleMember"),
+ roleLabels: user.isOwner
+ ? [t("accessRoleOwner")]
+ : (() => {
+ const names = (user.roles ?? [])
+ .map((r) => r.roleName)
+ .filter((n): n is string => Boolean(n?.length));
+ return names.length ? names : [t("accessRoleMember")];
+ })(),
isOwner: user.isOwner || false
};
});
diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx
index a533fb6c3..414a9b652 100644
--- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx
+++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx
@@ -129,12 +129,13 @@ export default function ResourceAuthenticationPage() {
orgId: org.org.orgId
})
);
- const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery(
- orgQueries.identityProviders({
+ const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery({
+ ...orgQueries.identityProviders({
orgId: org.org.orgId,
useOrgOnlyIdp: env.app.identityProviderMode === "org"
- })
- );
+ }),
+ enabled: isPaidUser(tierMatrix.orgOidc)
+ });
const pageLoading =
isLoadingOrgRoles ||
diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx
index 1f7c5279d..01be1aedb 100644
--- a/src/components/PermissionsSelectBox.tsx
+++ b/src/components/PermissionsSelectBox.tsx
@@ -95,7 +95,8 @@ function getActionsCategories(root: boolean) {
[t("actionListRole")]: "listRoles",
[t("actionUpdateRole")]: "updateRole",
[t("actionListAllowedRoleResources")]: "listRoleResources",
- [t("actionAddUserRole")]: "addUserRole"
+ [t("actionAddUserRole")]: "addUserRole",
+ [t("actionSetUserOrgRoles")]: "setUserOrgRoles"
},
"Access Token": {
[t("actionGenerateAccessToken")]: "generateAccessToken",
diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx
index 9b1dfee68..be1d6a345 100644
--- a/src/components/UsersTable.tsx
+++ b/src/components/UsersTable.tsx
@@ -12,6 +12,13 @@ import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "@app/components/UsersDataTable";
import { useState, useEffect } from "react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger
+} from "@app/components/ui/popover";
+import { Badge, badgeVariants } from "@app/components/ui/badge";
+import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
@@ -36,10 +43,65 @@ export type UserRow = {
type: string;
idpVariant: string | null;
status: string;
- role: string;
+ roleLabels: string[];
isOwner: boolean;
};
+const MAX_ROLE_BADGES = 3;
+
+function UserRoleBadges({ roleLabels }: { roleLabels: string[] }) {
+ const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
+ const overflow = roleLabels.slice(MAX_ROLE_BADGES);
+
+ return (
+
+ {visible.map((label, i) => (
+
+ {label}
+
+ ))}
+ {overflow.length > 0 && (
+
+ )}
+
+ );
+}
+
+function OverflowRolesPopover({ labels }: { labels: string[] }) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+ setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ >
+
+ {labels.map((label, i) => (
+ - {label}
+ ))}
+
+
+
+ );
+}
+
type UsersTableProps = {
users: UserRow[];
};
@@ -124,7 +186,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}
},
{
- accessorKey: "role",
+ id: "role",
+ accessorFn: (row) => row.roleLabels.join(", "),
friendlyName: t("role"),
header: ({ column }) => {
return (
@@ -140,13 +203,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
);
},
cell: ({ row }) => {
- const userRow = row.original;
-
- return (
-
- {userRow.role}
-
- );
+ return
;
}
},
{
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index 4d38e0fd4..2b474f45b 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const badgeVariants = cva(
- "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0",
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
{
variants: {
variant: {
From d046084e84cac2ff1cac3e0724aa5a093e333117 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 26 Mar 2026 16:44:30 -0700
Subject: [PATCH 03/19] delete role move to new role
---
server/routers/role/deleteRole.ts | 25 ++++++++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts
index 24c26e654..4d2797250 100644
--- a/server/routers/role/deleteRole.ts
+++ b/server/routers/role/deleteRole.ts
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roles, userOrgRoles } from "@server/db";
-import { eq } from "drizzle-orm";
+import { and, eq, exists, aliasedTable } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -114,13 +114,32 @@ export async function deleteRole(
}
await db.transaction(async (trx) => {
- // move all users from userOrgRoles with roleId to newRoleId
+ const uorNewRole = aliasedTable(userOrgRoles, "user_org_roles_new");
+
+ // Users who already have newRoleId: drop the old assignment only (unique on userId+orgId+roleId).
+ await trx.delete(userOrgRoles).where(
+ and(
+ eq(userOrgRoles.roleId, roleId),
+ exists(
+ trx
+ .select()
+ .from(uorNewRole)
+ .where(
+ and(
+ eq(uorNewRole.userId, userOrgRoles.userId),
+ eq(uorNewRole.orgId, userOrgRoles.orgId),
+ eq(uorNewRole.roleId, newRoleId)
+ )
+ )
+ )
+ )
+ );
+
await trx
.update(userOrgRoles)
.set({ roleId: newRoleId })
.where(eq(userOrgRoles.roleId, roleId));
- // delete the old role
await trx.delete(roles).where(eq(roles.roleId, roleId));
});
From 13eadeaa8f0f67f2624110e1e28e258b7d730bae Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 26 Mar 2026 18:19:10 -0700
Subject: [PATCH 04/19] support legacy one role per user
---
messages/en-US.json | 2 +
server/private/routers/external.ts | 36 +++-
server/private/routers/integration.ts | 23 +++
.../{ => private}/routers/user/addUserRole.ts | 19 ++-
server/private/routers/user/index.ts | 16 ++
.../routers/user/removeUserRole.ts | 20 ++-
.../routers/user/setUserOrgRoles.ts | 14 +-
server/routers/external.ts | 24 +--
server/routers/integration.ts | 22 +--
server/routers/user/addUserRoleLegacy.ts | 159 ++++++++++++++++++
server/routers/user/index.ts | 5 +-
server/routers/user/types.ts | 18 ++
.../users/[userId]/access-controls/page.tsx | 65 ++++++-
src/components/PermissionsSelectBox.tsx | 2 +-
14 files changed, 360 insertions(+), 65 deletions(-)
rename server/{ => private}/routers/user/addUserRole.ts (91%)
create mode 100644 server/private/routers/user/index.ts
rename server/{ => private}/routers/user/removeUserRole.ts (89%)
rename server/{ => private}/routers/user/setUserOrgRoles.ts (92%)
create mode 100644 server/routers/user/addUserRoleLegacy.ts
create mode 100644 server/routers/user/types.ts
diff --git a/messages/en-US.json b/messages/en-US.json
index cdfd57110..361771d87 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -512,6 +512,8 @@
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
"accessControlsDescription": "Manage what this user can access and do in the organization",
"accessControlsSubmit": "Save Access Controls",
+ "singleRolePerUserPlanNotice": "Your plan only supports one role per user.",
+ "singleRolePerUserEditionNotice": "This edition only supports one role per user.",
"roles": "Roles",
"accessUsersRoles": "Manage Users & Roles",
"accessUsersRolesDescription": "Invite users and add them to roles to manage access to the organization",
diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts
index df8ea8cbb..f5749d529 100644
--- a/server/private/routers/external.ts
+++ b/server/private/routers/external.ts
@@ -26,6 +26,7 @@ import * as misc from "#private/routers/misc";
import * as reKey from "#private/routers/re-key";
import * as approval from "#private/routers/approvals";
import * as ssh from "#private/routers/ssh";
+import * as user from "#private/routers/user";
import {
verifyOrgAccess,
@@ -33,7 +34,10 @@ import {
verifyUserIsServerAdmin,
verifySiteAccess,
verifyClientAccess,
- verifyLimits
+ verifyLimits,
+ verifyRoleAccess,
+ verifyUserAccess,
+ verifyUserCanSetUserOrgRoles
} from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions";
import {
@@ -518,3 +522,33 @@ authenticated.post(
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
ssh.signSshKey
);
+
+authenticated.post(
+ "/user/:userId/add-role/:roleId",
+ verifyRoleAccess,
+ verifyUserAccess,
+ verifyLimits,
+ verifyUserHasAction(ActionsEnum.addUserRole),
+ logActionAudit(ActionsEnum.addUserRole),
+ user.addUserRole
+);
+
+authenticated.delete(
+ "/user/:userId/remove-role/:roleId",
+ verifyRoleAccess,
+ verifyUserAccess,
+ verifyLimits,
+ verifyUserHasAction(ActionsEnum.removeUserRole),
+ logActionAudit(ActionsEnum.removeUserRole),
+ user.removeUserRole
+);
+
+authenticated.post(
+ "/user/:userId/org/:orgId/roles",
+ verifyOrgAccess,
+ verifyUserAccess,
+ verifyLimits,
+ verifyUserCanSetUserOrgRoles(),
+ logActionAudit(ActionsEnum.setUserOrgRoles),
+ user.setUserOrgRoles
+);
diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts
index 97b1adade..f8e6a63f4 100644
--- a/server/private/routers/integration.ts
+++ b/server/private/routers/integration.ts
@@ -20,8 +20,11 @@ import {
verifyApiKeyIsRoot,
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess,
+ verifyApiKeyRoleAccess,
+ verifyApiKeyUserAccess,
verifyLimits
} from "@server/middlewares";
+import * as user from "#private/routers/user";
import {
verifyValidSubscription,
verifyValidLicense
@@ -140,3 +143,23 @@ authenticated.get(
verifyApiKeyHasAction(ActionsEnum.listIdps),
orgIdp.listOrgIdps
);
+
+authenticated.post(
+ "/user/:userId/add-role/:roleId",
+ verifyApiKeyRoleAccess,
+ verifyApiKeyUserAccess,
+ verifyLimits,
+ verifyApiKeyHasAction(ActionsEnum.addUserRole),
+ logActionAudit(ActionsEnum.addUserRole),
+ user.addUserRole
+);
+
+authenticated.delete(
+ "/user/:userId/remove-role/:roleId",
+ verifyApiKeyRoleAccess,
+ verifyApiKeyUserAccess,
+ verifyLimits,
+ verifyApiKeyHasAction(ActionsEnum.removeUserRole),
+ logActionAudit(ActionsEnum.removeUserRole),
+ user.removeUserRole
+);
diff --git a/server/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts
similarity index 91%
rename from server/routers/user/addUserRole.ts
rename to server/private/routers/user/addUserRole.ts
index d41ad2051..a46bd1ed8 100644
--- a/server/routers/user/addUserRole.ts
+++ b/server/private/routers/user/addUserRole.ts
@@ -1,5 +1,19 @@
+/*
+ * This file is part of a proprietary work.
+ *
+ * Copyright (c) 2025 Fossorial, Inc.
+ * All rights reserved.
+ *
+ * This file is licensed under the Fossorial Commercial License.
+ * You may not use this file except in compliance with the License.
+ * Unauthorized use, copying, modification, or distribution is strictly prohibited.
+ *
+ * This file is not licensed under the AGPLv3.
+ */
+
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
+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";
@@ -8,7 +22,6 @@ 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";
@@ -17,11 +30,9 @@ const addUserRoleParamsSchema = z.strictObject({
roleId: z.string().transform(stoi).pipe(z.number())
});
-export type AddUserRoleResponse = z.infer;
-
registry.registerPath({
method: "post",
- path: "/role/{roleId}/add/{userId}",
+ path: "/user/{userId}/add-role/{roleId}",
description: "Add a role to a user.",
tags: [OpenAPITags.Role, OpenAPITags.User],
request: {
diff --git a/server/private/routers/user/index.ts b/server/private/routers/user/index.ts
new file mode 100644
index 000000000..6317eced5
--- /dev/null
+++ b/server/private/routers/user/index.ts
@@ -0,0 +1,16 @@
+/*
+ * This file is part of a proprietary work.
+ *
+ * Copyright (c) 2025 Fossorial, Inc.
+ * All rights reserved.
+ *
+ * This file is licensed under the Fossorial Commercial License.
+ * You may not use this file except in compliance with the License.
+ * Unauthorized use, copying, modification, or distribution is strictly prohibited.
+ *
+ * This file is not licensed under the AGPLv3.
+ */
+
+export * from "./addUserRole";
+export * from "./removeUserRole";
+export * from "./setUserOrgRoles";
diff --git a/server/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts
similarity index 89%
rename from server/routers/user/removeUserRole.ts
rename to server/private/routers/user/removeUserRole.ts
index 8d353fea3..e9c3d10c0 100644
--- a/server/routers/user/removeUserRole.ts
+++ b/server/private/routers/user/removeUserRole.ts
@@ -1,5 +1,19 @@
+/*
+ * This file is part of a proprietary work.
+ *
+ * Copyright (c) 2025 Fossorial, Inc.
+ * All rights reserved.
+ *
+ * This file is licensed under the Fossorial Commercial License.
+ * You may not use this file except in compliance with the License.
+ * Unauthorized use, copying, modification, or distribution is strictly prohibited.
+ *
+ * This file is not licensed under the AGPLv3.
+ */
+
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
+import stoi from "@server/lib/stoi";
import { db } from "@server/db";
import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
import { eq, and } from "drizzle-orm";
@@ -8,7 +22,6 @@ 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";
@@ -19,8 +32,9 @@ const removeUserRoleParamsSchema = z.strictObject({
registry.registerPath({
method: "delete",
- path: "/role/{roleId}/remove/{userId}",
- description: "Remove a role from a user. User must have at least one role left in the org.",
+ path: "/user/{userId}/remove-role/{roleId}",
+ description:
+ "Remove a role from a user. User must have at least one role left in the org.",
tags: [OpenAPITags.Role, OpenAPITags.User],
request: {
params: removeUserRoleParamsSchema
diff --git a/server/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts
similarity index 92%
rename from server/routers/user/setUserOrgRoles.ts
rename to server/private/routers/user/setUserOrgRoles.ts
index 525c91729..67563fd26 100644
--- a/server/routers/user/setUserOrgRoles.ts
+++ b/server/private/routers/user/setUserOrgRoles.ts
@@ -1,3 +1,16 @@
+/*
+ * This file is part of a proprietary work.
+ *
+ * Copyright (c) 2025 Fossorial, Inc.
+ * All rights reserved.
+ *
+ * This file is licensed under the Fossorial Commercial License.
+ * You may not use this file except in compliance with the License.
+ * Unauthorized use, copying, modification, or distribution is strictly prohibited.
+ *
+ * This file is not licensed under the AGPLv3.
+ */
+
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { clients, db } from "@server/db";
@@ -8,7 +21,6 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
-import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
const setUserOrgRolesParamsSchema = z.strictObject({
diff --git a/server/routers/external.ts b/server/routers/external.ts
index bb331dc69..03d5fa111 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -39,7 +39,6 @@ import {
verifyApiKeyAccess,
verifyDomainAccess,
verifyUserHasAction,
- verifyUserCanSetUserOrgRoles,
verifyUserIsOrgOwner,
verifySiteResourceAccess,
verifyOlmAccess,
@@ -645,6 +644,7 @@ authenticated.delete(
logActionAudit(ActionsEnum.deleteRole),
role.deleteRole
);
+
authenticated.post(
"/role/:roleId/add/:userId",
verifyRoleAccess,
@@ -652,17 +652,7 @@ authenticated.post(
verifyLimits,
verifyUserHasAction(ActionsEnum.addUserRole),
logActionAudit(ActionsEnum.addUserRole),
- user.addUserRole
-);
-
-authenticated.delete(
- "/role/:roleId/remove/:userId",
- verifyRoleAccess,
- verifyUserAccess,
- verifyLimits,
- verifyUserHasAction(ActionsEnum.removeUserRole),
- logActionAudit(ActionsEnum.removeUserRole),
- user.removeUserRole
+ user.addUserRoleLegacy
);
authenticated.post(
@@ -838,16 +828,6 @@ authenticated.post(
user.updateOrgUser
);
-authenticated.post(
- "/org/:orgId/user/:userId/roles",
- verifyOrgAccess,
- verifyUserAccess,
- verifyLimits,
- verifyUserCanSetUserOrgRoles(),
- logActionAudit(ActionsEnum.setUserOrgRoles),
- user.setUserOrgRoles
-);
-
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess);
diff --git a/server/routers/integration.ts b/server/routers/integration.ts
index 32c06078b..2865b4bcb 100644
--- a/server/routers/integration.ts
+++ b/server/routers/integration.ts
@@ -596,17 +596,7 @@ authenticated.post(
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.addUserRole),
logActionAudit(ActionsEnum.addUserRole),
- user.addUserRole
-);
-
-authenticated.delete(
- "/role/:roleId/remove/:userId",
- verifyApiKeyRoleAccess,
- verifyApiKeyUserAccess,
- verifyLimits,
- verifyApiKeyHasAction(ActionsEnum.removeUserRole),
- logActionAudit(ActionsEnum.removeUserRole),
- user.removeUserRole
+ user.addUserRoleLegacy
);
authenticated.post(
@@ -815,16 +805,6 @@ authenticated.post(
user.updateOrgUser
);
-authenticated.post(
- "/org/:orgId/user/:userId/roles",
- verifyApiKeyOrgAccess,
- verifyApiKeyUserAccess,
- verifyLimits,
- verifyApiKeyCanSetUserOrgRoles(),
- logActionAudit(ActionsEnum.setUserOrgRoles),
- user.setUserOrgRoles
-);
-
authenticated.delete(
"/org/:orgId/user/:userId",
verifyApiKeyOrgAccess,
diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts
new file mode 100644
index 000000000..db0c6182f
--- /dev/null
+++ b/server/routers/user/addUserRoleLegacy.ts
@@ -0,0 +1,159 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+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 { OpenAPITags, registry } from "@server/openApi";
+import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
+
+/** Legacy path param order: /role/:roleId/add/:userId */
+const addUserRoleLegacyParamsSchema = z.strictObject({
+ roleId: z.string().transform(stoi).pipe(z.number()),
+ userId: z.string()
+});
+
+registry.registerPath({
+ method: "post",
+ path: "/role/{roleId}/add/{userId}",
+ 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: addUserRoleLegacyParamsSchema
+ },
+ responses: {}
+});
+
+export async function addUserRoleLegacy(
+ req: Request,
+ res: Response,
+ next: NextFunction
+): Promise {
+ try {
+ const parsedParams = addUserRoleLegacyParamsSchema.safeParse(
+ req.params
+ );
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const { userId, roleId } = parsedParams.data;
+
+ if (req.user && !req.userOrg) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "You do not have access to this organization"
+ )
+ );
+ }
+
+ const [role] = await db
+ .select()
+ .from(roles)
+ .where(eq(roles.roleId, roleId))
+ .limit(1);
+
+ if (!role) {
+ return next(
+ createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
+ );
+ }
+
+ const [existingUser] = await db
+ .select()
+ .from(userOrgs)
+ .where(
+ and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId))
+ )
+ .limit(1);
+
+ if (!existingUser) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "User not found or does not belong to the specified organization"
+ )
+ );
+ }
+
+ if (existingUser.isOwner) {
+ return next(
+ createHttpError(
+ HttpCode.FORBIDDEN,
+ "Cannot change the role of the owner of the organization"
+ )
+ );
+ }
+
+ const [roleInOrg] = await db
+ .select()
+ .from(roles)
+ .where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId)))
+ .limit(1);
+
+ if (!roleInOrg) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ "Role not found or does not belong to the specified organization"
+ )
+ );
+ }
+
+ await db.transaction(async (trx) => {
+ await trx
+ .delete(userOrgRoles)
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, role.orgId)
+ )
+ );
+
+ await trx.insert(userOrgRoles).values({
+ userId,
+ orgId: role.orgId,
+ roleId
+ });
+
+ const orgClients = await trx
+ .select()
+ .from(clients)
+ .where(
+ and(
+ eq(clients.userId, userId),
+ eq(clients.orgId, role.orgId)
+ )
+ );
+
+ for (const orgClient of orgClients) {
+ await rebuildClientAssociationsFromClient(orgClient, trx);
+ }
+ });
+
+ return response(res, {
+ data: { ...existingUser, roleId },
+ success: true,
+ error: false,
+ message: "Role added to user successfully",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+ );
+ }
+}
diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts
index 0884e8a2b..e03676caa 100644
--- a/server/routers/user/index.ts
+++ b/server/routers/user/index.ts
@@ -1,9 +1,8 @@
export * from "./getUser";
export * from "./removeUserOrg";
export * from "./listUsers";
-export * from "./addUserRole";
-export * from "./removeUserRole";
-export * from "./setUserOrgRoles";
+export * from "./types";
+export * from "./addUserRoleLegacy";
export * from "./inviteUser";
export * from "./acceptInvite";
export * from "./getOrgUser";
diff --git a/server/routers/user/types.ts b/server/routers/user/types.ts
new file mode 100644
index 000000000..bd5b54efa
--- /dev/null
+++ b/server/routers/user/types.ts
@@ -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[];
+};
diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
index 6dcdf16fb..c9ed7d561 100644
--- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
+++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
@@ -37,6 +37,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import { build } from "@server/build";
const accessControlsFormSchema = z.object({
username: z.string(),
@@ -51,8 +54,9 @@ const accessControlsFormSchema = z.object({
export default function AccessControlsPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext();
+ const { env } = useEnvContext();
- const api = createApiClient(useEnvContext());
+ const api = createApiClient({ env });
const { orgId } = useParams();
@@ -63,6 +67,18 @@ export default function AccessControlsPage() {
);
const t = useTranslations();
+ const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } =
+ usePaidStatus();
+ const multiRoleFeatureTiers = Array.from(
+ new Set([...tierMatrix.sshPam, ...tierMatrix.orgOidc])
+ );
+ const isPaid = isPaidUser(multiRoleFeatureTiers);
+ const supportsMultipleRolesPerUser = isPaid;
+ const showMultiRolePaywallMessage =
+ !env.flags.disableEnterpriseFeatures &&
+ ((build === "saas" && !isPaid) ||
+ (build === "enterprise" && !isPaid) ||
+ (build === "oss" && !isPaid));
const form = useForm({
resolver: zodResolver(accessControlsFormSchema),
@@ -124,11 +140,28 @@ export default function AccessControlsPage() {
[roles]
);
- function setRoleTags(
- updater: Tag[] | ((prev: Tag[]) => Tag[])
- ) {
+ function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
const prev = form.getValues("roles");
- const next = typeof updater === "function" ? updater(prev) : updater;
+ const nextValue =
+ typeof updater === "function" ? updater(prev) : updater;
+ const next = supportsMultipleRolesPerUser
+ ? nextValue
+ : nextValue.length > 1
+ ? [nextValue[nextValue.length - 1]]
+ : nextValue;
+
+ // In single-role mode, selecting the currently selected role can transiently
+ // emit an empty tag list from TagInput; keep the prior selection.
+ if (
+ !supportsMultipleRolesPerUser &&
+ next.length === 0 &&
+ prev.length > 0
+ ) {
+ form.setValue("roles", [prev[prev.length - 1]], {
+ shouldDirty: true
+ });
+ return;
+ }
if (next.length === 0) {
toast({
@@ -155,11 +188,14 @@ export default function AccessControlsPage() {
setLoading(true);
try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
+ const updateRoleRequest = supportsMultipleRolesPerUser
+ ? api.post(`/user/${user.userId}/org/${orgId}/roles`, {
+ roleIds
+ })
+ : api.post(`/role/${roleIds[0]}/add/${user.userId}`);
await Promise.all([
- api.post(`/org/${orgId}/user/${user.userId}/roles`, {
- roleIds
- }),
+ updateRoleRequest,
api.post(`/org/${orgId}/user/${user.userId}`, {
autoProvisioned: values.autoProvisioned
})
@@ -233,7 +269,7 @@ export default function AccessControlsPage() {
name="roles"
render={({ field }) => (
- {t("role")}
+ {t("roles")}
+ {showMultiRolePaywallMessage && (
+
+ {build === "saas"
+ ? t(
+ "singleRolePerUserPlanNotice"
+ )
+ : t(
+ "singleRolePerUserEditionNotice"
+ )}
+
+ )}
)}
diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx
index 01be1aedb..80a4b9926 100644
--- a/src/components/PermissionsSelectBox.tsx
+++ b/src/components/PermissionsSelectBox.tsx
@@ -96,7 +96,7 @@ function getActionsCategories(root: boolean) {
[t("actionUpdateRole")]: "updateRole",
[t("actionListAllowedRoleResources")]: "listRoleResources",
[t("actionAddUserRole")]: "addUserRole",
- [t("actionSetUserOrgRoles")]: "setUserOrgRoles"
+ [t("actionRemoveUserRole")]: "removeUserRole"
},
"Access Token": {
[t("actionGenerateAccessToken")]: "generateAccessToken",
From ad7d68d2b443ea4b04a174277830040c072cf2f2 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Thu, 26 Mar 2026 21:46:01 -0700
Subject: [PATCH 05/19] basic idp mapping builder
---
server/routers/idp/validateOidcCallback.ts | 94 +++--
.../(private)/idp/[idpId]/general/page.tsx | 167 +++++----
.../settings/(private)/idp/create/page.tsx | 119 +++---
src/components/AutoProvisionConfigWidget.tsx | 349 +++++++++++++-----
src/lib/idpRoleMapping.ts | 266 +++++++++++++
5 files changed, 755 insertions(+), 240 deletions(-)
create mode 100644 src/lib/idpRoleMapping.ts
diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts
index 8714c4d38..86545269b 100644
--- a/server/routers/idp/validateOidcCallback.ts
+++ b/server/routers/idp/validateOidcCallback.ts
@@ -41,6 +41,7 @@ import {
assignUserToOrg,
removeUserFromOrg
} from "@server/lib/userOrg";
+import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
const ensureTrailingSlash = (url: string): string => {
return url;
@@ -367,7 +368,7 @@ export async function validateOidcCallback(
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
- const userOrgInfo: { orgId: string; roleId: number }[] = [];
+ const userOrgInfo: { orgId: string; roleIds: number[] }[] = [];
for (const org of allOrgs) {
const [idpOrgRes] = await db
.select()
@@ -379,8 +380,6 @@ export async function validateOidcCallback(
)
);
- let roleId: number | undefined = undefined;
-
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = hydrateOrgMapping(
orgMapping,
@@ -405,38 +404,47 @@ export async function validateOidcCallback(
idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) {
logger.debug("Role Mapping", { roleMapping });
- const roleName = jmespath.search(claims, roleMapping);
+ const roleMappingJmes = unwrapRoleMapping(
+ roleMapping
+ ).evaluationExpression;
+ const roleMappingResult = jmespath.search(
+ claims,
+ roleMappingJmes
+ );
+ const roleNames = normalizeRoleMappingResult(
+ roleMappingResult
+ );
- if (!roleName) {
- logger.error("Role name not found in the ID token", {
- roleName
+ if (!roleNames.length) {
+ logger.error("Role mapping returned no valid roles", {
+ roleMappingResult
});
continue;
}
- const [roleRes] = await db
+ const roleRes = await db
.select()
.from(roles)
.where(
and(
eq(roles.orgId, org.orgId),
- eq(roles.name, roleName)
+ inArray(roles.name, roleNames)
)
);
- if (!roleRes) {
- logger.error("Role not found", {
+ if (!roleRes.length) {
+ logger.error("No mapped roles found in organization", {
orgId: org.orgId,
- roleName
+ roleNames
});
continue;
}
- roleId = roleRes.roleId;
+ const roleIds = [...new Set(roleRes.map((r) => r.roleId))];
userOrgInfo.push({
orgId: org.orgId,
- roleId
+ roleIds
});
}
}
@@ -584,15 +592,17 @@ export async function validateOidcCallback(
const currentRolesInOrg = userRolesInOrgs.filter(
(r) => r.orgId === currentOrg.orgId
);
- const hasIdpRole = currentRolesInOrg.some(
- (r) => r.roleId === newRole.roleId
- );
- if (!hasIdpRole) {
- await trx.insert(userOrgRoles).values({
- userId: userId!,
- orgId: currentOrg.orgId,
- roleId: newRole.roleId
- });
+ for (const roleId of newRole.roleIds) {
+ const hasIdpRole = currentRolesInOrg.some(
+ (r) => r.roleId === roleId
+ );
+ if (!hasIdpRole) {
+ await trx.insert(userOrgRoles).values({
+ userId: userId!,
+ orgId: currentOrg.orgId,
+ roleId
+ });
+ }
}
}
@@ -606,6 +616,12 @@ export async function validateOidcCallback(
if (orgsToAdd.length > 0) {
for (const org of orgsToAdd) {
+ const [initialRoleId, ...additionalRoleIds] =
+ org.roleIds;
+ if (!initialRoleId) {
+ continue;
+ }
+
const [fullOrg] = await trx
.select()
.from(orgs)
@@ -618,9 +634,17 @@ export async function validateOidcCallback(
userId: userId!,
autoProvisioned: true,
},
- org.roleId,
+ initialRoleId,
trx
);
+
+ for (const roleId of additionalRoleIds) {
+ await trx.insert(userOrgRoles).values({
+ userId: userId!,
+ orgId: org.orgId,
+ roleId
+ });
+ }
}
}
}
@@ -745,3 +769,25 @@ function hydrateOrgMapping(
}
return orgMapping.split("{{orgId}}").join(orgId);
}
+
+function normalizeRoleMappingResult(
+ result: unknown
+): string[] {
+ if (typeof result === "string") {
+ const role = result.trim();
+ return role ? [role] : [];
+ }
+
+ if (Array.isArray(result)) {
+ return [
+ ...new Set(
+ result
+ .filter((value): value is string => typeof value === "string")
+ .map((value) => value.trim())
+ .filter(Boolean)
+ )
+ ];
+ }
+
+ return [];
+}
diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
index 7d4bece1e..9754b07e5 100644
--- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
+++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
@@ -47,6 +47,14 @@ import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import {
+ compileRoleMappingExpression,
+ createMappingBuilderRule,
+ detectRoleMappingConfig,
+ ensureMappingBuilderRuleIds,
+ MappingBuilderRule,
+ RoleMappingMode
+} from "@app/lib/idpRoleMapping";
export default function GeneralPage() {
const { env } = useEnvContext();
@@ -56,9 +64,15 @@ export default function GeneralPage() {
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
- const [roleMappingMode, setRoleMappingMode] = useState<
- "role" | "expression"
- >("role");
+ const [roleMappingMode, setRoleMappingMode] =
+ useState("fixedRoles");
+ const [fixedRoleNames, setFixedRoleNames] = useState([]);
+ const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
+ useState("groups");
+ const [mappingBuilderRules, setMappingBuilderRules] = useState<
+ MappingBuilderRule[]
+ >([createMappingBuilderRule()]);
+ const [rawRoleExpression, setRawRoleExpression] = useState("");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
@@ -190,34 +204,8 @@ export default function GeneralPage() {
// Set the variant
setVariant(idpVariant as "oidc" | "google" | "azure");
- // Check if roleMapping matches the basic pattern '{role name}' (simple single role)
- // This should NOT match complex expressions like 'Admin' || 'Member'
- const isBasicRolePattern =
- roleMapping &&
- typeof roleMapping === "string" &&
- /^'[^']+'$/.test(roleMapping);
-
- // Determine if roleMapping is a number (roleId) or matches basic pattern
- const isRoleId =
- !isNaN(Number(roleMapping)) && roleMapping !== "";
- const isRoleName = isBasicRolePattern;
-
- // Extract role name from basic pattern for matching
- let extractedRoleName = null;
- if (isRoleName) {
- extractedRoleName = roleMapping.slice(1, -1); // Remove quotes
- }
-
- // Try to find matching role by name if we have a basic pattern
- let matchingRoleId = undefined;
- if (extractedRoleName && availableRoles.length > 0) {
- const matchingRole = availableRoles.find(
- (role) => role.name === extractedRoleName
- );
- if (matchingRole) {
- matchingRoleId = matchingRole.roleId;
- }
- }
+ const detectedRoleMappingConfig =
+ detectRoleMappingConfig(roleMapping);
// Extract tenant ID from Azure URLs if present
let tenantId = "";
@@ -238,9 +226,7 @@ export default function GeneralPage() {
clientSecret: data.idpOidcConfig.clientSecret,
autoProvision: data.idp.autoProvision,
roleMapping: roleMapping || null,
- roleId: isRoleId
- ? Number(roleMapping)
- : matchingRoleId || null
+ roleId: null
};
// Add variant-specific fields
@@ -259,10 +245,18 @@ export default function GeneralPage() {
form.reset(formData);
- // Set the role mapping mode based on the data
- // Default to "expression" unless it's a simple roleId or basic '{role name}' pattern
- setRoleMappingMode(
- matchingRoleId && isRoleName ? "role" : "expression"
+ setRoleMappingMode(detectedRoleMappingConfig.mode);
+ setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames);
+ setMappingBuilderClaimPath(
+ detectedRoleMappingConfig.mappingBuilder.claimPath
+ );
+ setMappingBuilderRules(
+ ensureMappingBuilderRuleIds(
+ detectedRoleMappingConfig.mappingBuilder.rules
+ )
+ );
+ setRawRoleExpression(
+ detectedRoleMappingConfig.rawExpression
);
}
} catch (e) {
@@ -327,7 +321,26 @@ export default function GeneralPage() {
return;
}
- const roleName = roles.find((r) => r.roleId === data.roleId)?.name;
+ const roleMappingExpression = compileRoleMappingExpression({
+ mode: roleMappingMode,
+ fixedRoleNames,
+ mappingBuilder: {
+ claimPath: mappingBuilderClaimPath,
+ rules: mappingBuilderRules
+ },
+ rawExpression: rawRoleExpression
+ });
+
+ if (data.autoProvision && !roleMappingExpression) {
+ toast({
+ title: t("error"),
+ description:
+ "A role mapping is required when auto-provisioning is enabled.",
+ variant: "destructive"
+ });
+ setLoading(false);
+ return;
+ }
// Build payload based on variant
let payload: any = {
@@ -335,10 +348,7 @@ export default function GeneralPage() {
clientId: data.clientId,
clientSecret: data.clientSecret,
autoProvision: data.autoProvision,
- roleMapping:
- roleMappingMode === "role"
- ? `'${roleName}'`
- : data.roleMapping || ""
+ roleMapping: roleMappingExpression
};
// Add variant-specific fields
@@ -497,42 +507,43 @@ export default function GeneralPage() {
-
-
+
-
-
-
+
+
diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx
index 4c783e9b2..2d6985849 100644
--- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx
+++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx
@@ -42,6 +42,12 @@ import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
+import {
+ compileRoleMappingExpression,
+ createMappingBuilderRule,
+ MappingBuilderRule,
+ RoleMappingMode
+} from "@app/lib/idpRoleMapping";
export default function Page() {
const { env } = useEnvContext();
@@ -49,9 +55,15 @@ export default function Page() {
const router = useRouter();
const [createLoading, setCreateLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
- const [roleMappingMode, setRoleMappingMode] = useState<
- "role" | "expression"
- >("role");
+ const [roleMappingMode, setRoleMappingMode] =
+ useState("fixedRoles");
+ const [fixedRoleNames, setFixedRoleNames] = useState([]);
+ const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
+ useState("groups");
+ const [mappingBuilderRules, setMappingBuilderRules] = useState<
+ MappingBuilderRule[]
+ >([createMappingBuilderRule()]);
+ const [rawRoleExpression, setRawRoleExpression] = useState("");
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
@@ -228,7 +240,26 @@ export default function Page() {
tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId);
}
- const roleName = roles.find((r) => r.roleId === data.roleId)?.name;
+ const roleMappingExpression = compileRoleMappingExpression({
+ mode: roleMappingMode,
+ fixedRoleNames,
+ mappingBuilder: {
+ claimPath: mappingBuilderClaimPath,
+ rules: mappingBuilderRules
+ },
+ rawExpression: rawRoleExpression
+ });
+
+ if (data.autoProvision && !roleMappingExpression) {
+ toast({
+ title: t("error"),
+ description:
+ "A role mapping is required when auto-provisioning is enabled.",
+ variant: "destructive"
+ });
+ setCreateLoading(false);
+ return;
+ }
const payload = {
name: data.name,
@@ -240,10 +271,7 @@ export default function Page() {
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
- roleMapping:
- roleMappingMode === "role"
- ? `'${roleName}'`
- : data.roleMapping || "",
+ roleMapping: roleMappingExpression,
scopes: data.scopes,
variant: data.type
};
@@ -363,43 +391,44 @@ export default function Page() {
-
-
-
-
-
+
+
+
diff --git a/src/components/AutoProvisionConfigWidget.tsx b/src/components/AutoProvisionConfigWidget.tsx
index f5979aec3..44ee8c6e0 100644
--- a/src/components/AutoProvisionConfigWidget.tsx
+++ b/src/components/AutoProvisionConfigWidget.tsx
@@ -1,57 +1,74 @@
"use client";
import {
- FormField,
- FormItem,
FormLabel,
- FormControl,
- FormDescription,
- FormMessage
+ FormDescription
} from "@app/components/ui/form";
import { SwitchInput } from "@app/components/SwitchInput";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue
-} from "@app/components/ui/select";
+import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
-import { Control, FieldValues, Path } from "react-hook-form";
+import { useMemo, useState } from "react";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import { Tag, TagInput } from "@app/components/tags/tag-input";
+import {
+ createMappingBuilderRule,
+ MappingBuilderRule,
+ RoleMappingMode
+} from "@app/lib/idpRoleMapping";
type Role = {
roleId: number;
name: string;
};
-type AutoProvisionConfigWidgetProps = {
- control: Control;
+type AutoProvisionConfigWidgetProps = {
autoProvision: boolean;
onAutoProvisionChange: (checked: boolean) => void;
- roleMappingMode: "role" | "expression";
- onRoleMappingModeChange: (mode: "role" | "expression") => void;
+ roleMappingMode: RoleMappingMode;
+ onRoleMappingModeChange: (mode: RoleMappingMode) => void;
roles: Role[];
- roleIdFieldName: Path;
- roleMappingFieldName: Path;
+ fixedRoleNames: string[];
+ onFixedRoleNamesChange: (roleNames: string[]) => void;
+ mappingBuilderClaimPath: string;
+ onMappingBuilderClaimPathChange: (claimPath: string) => void;
+ mappingBuilderRules: MappingBuilderRule[];
+ onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
+ rawExpression: string;
+ onRawExpressionChange: (expression: string) => void;
};
-export default function AutoProvisionConfigWidget({
- control,
+export default function AutoProvisionConfigWidget({
autoProvision,
onAutoProvisionChange,
roleMappingMode,
onRoleMappingModeChange,
roles,
- roleIdFieldName,
- roleMappingFieldName
-}: AutoProvisionConfigWidgetProps) {
+ fixedRoleNames,
+ onFixedRoleNamesChange,
+ mappingBuilderClaimPath,
+ onMappingBuilderClaimPathChange,
+ mappingBuilderRules,
+ onMappingBuilderRulesChange,
+ rawExpression,
+ onRawExpressionChange
+}: AutoProvisionConfigWidgetProps) {
const t = useTranslations();
-
const { isPaidUser } = usePaidStatus();
+ const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
+ number | null
+ >(null);
+
+ const roleOptions = useMemo(
+ () =>
+ roles.map((role) => ({
+ id: role.name,
+ text: role.name
+ })),
+ [roles]
+ );
return (
@@ -81,97 +98,243 @@ export default function AutoProvisionConfigWidget
({
-
+
+
+
+
+
- {roleMappingMode === "role" ? (
- (
-
-
-
- {t("selectRoleDescription")}
-
-
-
- )}
- />
- ) : (
- (
-
-
-
-
-
- {t("roleMappingExpressionDescription")}
-
-
-
- )}
- />
+ {roleMappingMode === "fixedRoles" && (
+
+ ({
+ id: name,
+ text: name
+ }))}
+ setTags={(nextTags) => {
+ const next =
+ typeof nextTags === "function"
+ ? nextTags(
+ fixedRoleNames.map((name) => ({
+ id: name,
+ text: name
+ }))
+ )
+ : nextTags;
+
+ onFixedRoleNamesChange(
+ [...new Set(next.map((tag) => tag.text))]
+ );
+ }}
+ activeTagIndex={activeFixedRoleTagIndex}
+ setActiveTagIndex={setActiveFixedRoleTagIndex}
+ placeholder="Select one or more roles"
+ enableAutocomplete={true}
+ autocompleteOptions={roleOptions}
+ restrictTagsToAutocompleteOptions={true}
+ allowDuplicates={false}
+ sortTags={true}
+ size="sm"
+ />
+
+ Assign the same role set to every auto-provisioned
+ user.
+
+
+ )}
+
+ {roleMappingMode === "mappingBuilder" && (
+
+
+ Claim path
+
+ onMappingBuilderClaimPathChange(
+ e.target.value
+ )
+ }
+ placeholder="groups"
+ />
+
+ Path in the token payload that contains source
+ values (for example, groups).
+
+
+
+
+
+ Match value
+ Assign roles
+
+
+
+ {mappingBuilderRules.map((rule, index) => (
+
{
+ const nextRules =
+ mappingBuilderRules.map(
+ (row, i) =>
+ i === index
+ ? nextRule
+ : row
+ );
+ onMappingBuilderRulesChange(
+ nextRules
+ );
+ }}
+ onRemove={() => {
+ const nextRules =
+ mappingBuilderRules.filter(
+ (_, i) => i !== index
+ );
+ onMappingBuilderRulesChange(
+ nextRules.length
+ ? nextRules
+ : [createMappingBuilderRule()]
+ );
+ }}
+ />
+ ))}
+
+
+
+
+ )}
+
+ {roleMappingMode === "rawExpression" && (
+
+
+ onRawExpressionChange(e.target.value)
+ }
+ placeholder={t("roleMappingExpressionPlaceholder")}
+ />
+
+ Expression must evaluate to a string or string
+ array.
+
+
)}
)}
);
}
+
+function BuilderRuleRow({
+ rule,
+ roleOptions,
+ onChange,
+ onRemove
+}: {
+ rule: MappingBuilderRule;
+ roleOptions: Tag[];
+ onChange: (rule: MappingBuilderRule) => void;
+ onRemove: () => void;
+}) {
+ const [activeTagIndex, setActiveTagIndex] = useState(null);
+
+ return (
+
+
+ Match value
+
+ onChange({
+ ...rule,
+ matchValue: e.target.value
+ })
+ }
+ placeholder="Match value (for example: admin)"
+ />
+
+
+ Assign roles
+ ({ id: name, text: name }))}
+ setTags={(nextTags) => {
+ const next =
+ typeof nextTags === "function"
+ ? nextTags(
+ rule.roleNames.map((name) => ({
+ id: name,
+ text: name
+ }))
+ )
+ : nextTags;
+ onChange({
+ ...rule,
+ roleNames: [...new Set(next.map((tag) => tag.text))]
+ });
+ }}
+ activeTagIndex={activeTagIndex}
+ setActiveTagIndex={setActiveTagIndex}
+ placeholder="Assign roles"
+ enableAutocomplete={true}
+ autocompleteOptions={roleOptions}
+ restrictTagsToAutocompleteOptions={true}
+ allowDuplicates={false}
+ sortTags={true}
+ size="sm"
+ />
+
+
+
+
+
+ );
+}
diff --git a/src/lib/idpRoleMapping.ts b/src/lib/idpRoleMapping.ts
new file mode 100644
index 000000000..2b336b3dd
--- /dev/null
+++ b/src/lib/idpRoleMapping.ts
@@ -0,0 +1,266 @@
+export type RoleMappingMode = "fixedRoles" | "mappingBuilder" | "rawExpression";
+
+export type MappingBuilderRule = {
+ /** Stable React list key; not used when compiling JMESPath. */
+ id?: string;
+ matchValue: string;
+ roleNames: string[];
+};
+
+function newMappingBuilderRuleId(): string {
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
+ return crypto.randomUUID();
+ }
+ return `rule-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
+}
+
+export function createMappingBuilderRule(): MappingBuilderRule {
+ return {
+ id: newMappingBuilderRuleId(),
+ matchValue: "",
+ roleNames: []
+ };
+}
+
+/** Ensures every rule has a stable id (e.g. after loading from the API). */
+export function ensureMappingBuilderRuleIds(
+ rules: MappingBuilderRule[]
+): MappingBuilderRule[] {
+ return rules.map((rule) =>
+ rule.id ? rule : { ...rule, id: newMappingBuilderRuleId() }
+ );
+}
+
+export type MappingBuilderConfig = {
+ claimPath: string;
+ rules: MappingBuilderRule[];
+};
+
+export type RoleMappingConfig = {
+ mode: RoleMappingMode;
+ fixedRoleNames: string[];
+ mappingBuilder: MappingBuilderConfig;
+ rawExpression: string;
+};
+
+const SINGLE_QUOTED_ROLE_REGEX = /^'([^']+)'$/;
+const QUOTED_ROLE_ARRAY_REGEX = /^\[(.*)\]$/;
+
+/** Stored role mappings created by the mapping builder are prefixed so the UI can restore the builder. */
+export const PANGOLIN_ROLE_MAP_BUILDER_PREFIX = "__PANGOLIN_ROLE_MAP_BUILDER_V1__";
+
+const BUILDER_METADATA_SEPARATOR = "\n---\n";
+
+export type UnwrappedRoleMapping = {
+ /** Expression passed to JMESPath (no builder wrapper). */
+ evaluationExpression: string;
+ /** Present when the stored value was saved from the mapping builder. */
+ builderState: { claimPath: string; rules: MappingBuilderRule[] } | null;
+};
+
+/**
+ * Split stored DB value into evaluation expression and optional builder metadata.
+ * Legacy values (no prefix) are returned as-is for evaluation.
+ */
+export function unwrapRoleMapping(
+ stored: string | null | undefined
+): UnwrappedRoleMapping {
+ const trimmed = stored?.trim() ?? "";
+ if (!trimmed.startsWith(PANGOLIN_ROLE_MAP_BUILDER_PREFIX)) {
+ return {
+ evaluationExpression: trimmed,
+ builderState: null
+ };
+ }
+
+ let rest = trimmed.slice(PANGOLIN_ROLE_MAP_BUILDER_PREFIX.length);
+ if (rest.startsWith("\n")) {
+ rest = rest.slice(1);
+ }
+
+ const sepIdx = rest.indexOf(BUILDER_METADATA_SEPARATOR);
+ if (sepIdx === -1) {
+ return {
+ evaluationExpression: trimmed,
+ builderState: null
+ };
+ }
+
+ const jsonPart = rest.slice(0, sepIdx).trim();
+ const inner = rest.slice(sepIdx + BUILDER_METADATA_SEPARATOR.length).trim();
+
+ try {
+ const meta = JSON.parse(jsonPart) as {
+ claimPath?: unknown;
+ rules?: unknown;
+ };
+ if (
+ typeof meta.claimPath === "string" &&
+ Array.isArray(meta.rules)
+ ) {
+ const rules: MappingBuilderRule[] = meta.rules.map(
+ (r: unknown) => {
+ const row = r as {
+ matchValue?: unknown;
+ roleNames?: unknown;
+ };
+ return {
+ matchValue:
+ typeof row.matchValue === "string"
+ ? row.matchValue
+ : "",
+ roleNames: Array.isArray(row.roleNames)
+ ? row.roleNames.filter(
+ (n): n is string => typeof n === "string"
+ )
+ : []
+ };
+ }
+ );
+ return {
+ evaluationExpression: inner,
+ builderState: {
+ claimPath: meta.claimPath,
+ rules: ensureMappingBuilderRuleIds(rules)
+ }
+ };
+ }
+ } catch {
+ /* fall through */
+ }
+
+ return {
+ evaluationExpression: inner.length ? inner : trimmed,
+ builderState: null
+ };
+}
+
+function escapeSingleQuotes(value: string): string {
+ return value.replace(/'/g, "\\'");
+}
+
+export function compileRoleMappingExpression(config: RoleMappingConfig): string {
+ if (config.mode === "rawExpression") {
+ return config.rawExpression.trim();
+ }
+
+ if (config.mode === "fixedRoles") {
+ const roleNames = dedupeNonEmpty(config.fixedRoleNames);
+ if (!roleNames.length) {
+ return "";
+ }
+
+ if (roleNames.length === 1) {
+ return `'${escapeSingleQuotes(roleNames[0])}'`;
+ }
+
+ return `[${roleNames.map((name) => `'${escapeSingleQuotes(name)}'`).join(", ")}]`;
+ }
+
+ const claimPath = config.mappingBuilder.claimPath.trim();
+ const rules = config.mappingBuilder.rules
+ .map((rule) => ({
+ matchValue: rule.matchValue.trim(),
+ roleNames: dedupeNonEmpty(rule.roleNames)
+ }))
+ .filter((rule) => Boolean(rule.matchValue) && rule.roleNames.length > 0);
+
+ if (!claimPath || !rules.length) {
+ return "";
+ }
+
+ const compiledRules = rules.map((rule) => {
+ const mappedRoles = `[${rule.roleNames
+ .map((name) => `'${escapeSingleQuotes(name)}'`)
+ .join(", ")}]`;
+ return `contains(${claimPath}, '${escapeSingleQuotes(rule.matchValue)}') && ${mappedRoles} || []`;
+ });
+
+ const inner = `[${compiledRules.join(", ")}][]`;
+ const metadata = {
+ claimPath,
+ rules: rules.map((r) => ({
+ matchValue: r.matchValue,
+ roleNames: r.roleNames
+ }))
+ };
+
+ return `${PANGOLIN_ROLE_MAP_BUILDER_PREFIX}\n${JSON.stringify(metadata)}${BUILDER_METADATA_SEPARATOR}${inner}`;
+}
+
+export function detectRoleMappingConfig(
+ expression: string | null | undefined
+): RoleMappingConfig {
+ const stored = expression?.trim() || "";
+
+ if (!stored) {
+ return defaultRoleMappingConfig();
+ }
+
+ const { evaluationExpression, builderState } = unwrapRoleMapping(stored);
+
+ if (builderState) {
+ return {
+ mode: "mappingBuilder",
+ fixedRoleNames: [],
+ mappingBuilder: {
+ claimPath: builderState.claimPath,
+ rules: builderState.rules
+ },
+ rawExpression: evaluationExpression
+ };
+ }
+
+ const tail = evaluationExpression.trim();
+
+ const singleMatch = tail.match(SINGLE_QUOTED_ROLE_REGEX);
+ if (singleMatch?.[1]) {
+ return {
+ mode: "fixedRoles",
+ fixedRoleNames: [singleMatch[1]],
+ mappingBuilder: defaultRoleMappingConfig().mappingBuilder,
+ rawExpression: tail
+ };
+ }
+
+ const arrayMatch = tail.match(QUOTED_ROLE_ARRAY_REGEX);
+ if (arrayMatch?.[1]) {
+ const roleNames = arrayMatch[1]
+ .split(",")
+ .map((entry) => entry.trim())
+ .map((entry) => entry.match(SINGLE_QUOTED_ROLE_REGEX)?.[1] || "")
+ .filter(Boolean);
+
+ if (roleNames.length > 0) {
+ return {
+ mode: "fixedRoles",
+ fixedRoleNames: roleNames,
+ mappingBuilder: defaultRoleMappingConfig().mappingBuilder,
+ rawExpression: tail
+ };
+ }
+ }
+
+ return {
+ mode: "rawExpression",
+ fixedRoleNames: [],
+ mappingBuilder: defaultRoleMappingConfig().mappingBuilder,
+ rawExpression: tail
+ };
+}
+
+export function defaultRoleMappingConfig(): RoleMappingConfig {
+ return {
+ mode: "fixedRoles",
+ fixedRoleNames: [],
+ mappingBuilder: {
+ claimPath: "groups",
+ rules: [createMappingBuilderRule()]
+ },
+ rawExpression: ""
+ };
+}
+
+function dedupeNonEmpty(values: string[]): string[] {
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
+}
From 177926932b79d1162987a9a7d4e3d78121bb4022 Mon Sep 17 00:00:00 2001
From: Owen
Date: Fri, 27 Mar 2026 17:07:58 -0700
Subject: [PATCH 06/19] Update hybrid for multi role
---
server/private/routers/hybrid.ts | 90 +++++++++++++++++++++++++++-----
1 file changed, 77 insertions(+), 13 deletions(-)
diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts
index a38385b0c..5ca720594 100644
--- a/server/private/routers/hybrid.ts
+++ b/server/private/routers/hybrid.ts
@@ -52,7 +52,9 @@ import {
userOrgs,
roleResources,
userResources,
- resourceRules
+ resourceRules,
+ userOrgRoles,
+ roles
} from "@server/db";
import { eq, and, inArray, isNotNull, ne } from "drizzle-orm";
import { response } from "@server/lib/response";
@@ -104,6 +106,13 @@ const getUserOrgSessionVerifySchema = z.strictObject({
sessionId: z.string().min(1, "Session ID is required")
});
+const getRoleNameParamsSchema = z.strictObject({
+ roleId: z
+ .string()
+ .transform(Number)
+ .pipe(z.int().positive("Role ID must be a positive integer"))
+});
+
const getRoleResourceAccessParamsSchema = z.strictObject({
roleId: z
.string()
@@ -796,23 +805,26 @@ hybridRouter.get(
);
}
- const userOrgRole = await db
- .select()
- .from(userOrgs)
+ const userOrgRoleRows = await db
+ .select({ roleId: userOrgRoles.roleId })
+ .from(userOrgRoles)
.where(
- and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
- )
- .limit(1);
+ and(
+ eq(userOrgRoles.userId, userId),
+ eq(userOrgRoles.orgId, orgId)
+ )
+ );
- const result = userOrgRole.length > 0 ? userOrgRole[0] : null;
+ const roleIds = userOrgRoleRows.map((r) => r.roleId);
- return response(res, {
- data: result,
+ return response(res, {
+ data: roleIds,
success: true,
error: false,
- message: result
- ? "User org role retrieved successfully"
- : "User org role not found",
+ message:
+ roleIds.length > 0
+ ? "User org roles retrieved successfully"
+ : "User has no roles in this organization",
status: HttpCode.OK
});
} catch (error) {
@@ -890,6 +902,58 @@ hybridRouter.get(
}
);
+// Get role name by ID
+hybridRouter.get(
+ "/role/:roleId/name",
+ async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const parsedParams = getRoleNameParamsSchema.safeParse(req.params);
+ if (!parsedParams.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedParams.error).toString()
+ )
+ );
+ }
+
+ const { roleId } = parsedParams.data;
+ const remoteExitNode = req.remoteExitNode;
+
+ if (!remoteExitNode?.exitNodeId) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ "Remote exit node not found"
+ )
+ );
+ }
+
+ const [role] = await db
+ .select({ name: roles.name })
+ .from(roles)
+ .where(eq(roles.roleId, roleId))
+ .limit(1);
+
+ return response(res, {
+ data: role?.name ?? null,
+ success: true,
+ error: false,
+ message: role ? "Role name retrieved successfully" : "Role not found",
+ status: HttpCode.OK
+ });
+ } catch (error) {
+ logger.error(error);
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Failed to get role name"
+ )
+ );
+ }
+ }
+);
+
// Check if role has access to resource
hybridRouter.get(
"/role/:roleId/resource/:resourceId/access",
From bea20674a8efd8ab9e7b1b048d6e76a89d8b3ec6 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Fri, 27 Mar 2026 17:35:35 -0700
Subject: [PATCH 07/19] support policy buildiner in global idp
---
messages/en-US.json | 31 +-
src/app/admin/idp/[idpId]/general/page.tsx | 64 ++-
src/app/admin/idp/[idpId]/layout.tsx | 2 +-
src/app/admin/idp/[idpId]/policies/page.tsx | 385 +++++++++++++------
src/app/admin/idp/create/page.tsx | 33 --
src/components/AutoProvisionConfigWidget.tsx | 293 +-------------
src/components/RoleMappingConfigFields.tsx | 366 ++++++++++++++++++
7 files changed, 703 insertions(+), 471 deletions(-)
create mode 100644 src/components/RoleMappingConfigFields.tsx
diff --git a/messages/en-US.json b/messages/en-US.json
index 361771d87..7d00c8105 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -509,6 +509,7 @@
"userSaved": "User saved",
"userSavedDescription": "The user has been updated.",
"autoProvisioned": "Auto Provisioned",
+ "autoProvisionSettings": "Auto Provision Settings",
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
"accessControlsDescription": "Manage what this user can access and do in the organization",
"accessControlsSubmit": "Save Access Controls",
@@ -1042,7 +1043,6 @@
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
"overview": "Overview",
"home": "Home",
- "accessControl": "Access Control",
"settings": "Settings",
"usersAll": "All Users",
"license": "License",
@@ -1942,6 +1942,24 @@
"invalidValue": "Invalid value",
"idpTypeLabel": "Identity Provider Type",
"roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'",
+ "roleMappingModeFixedRoles": "Fixed roles",
+ "roleMappingModeMappingBuilder": "Mapping builder",
+ "roleMappingModeRawExpression": "Raw expression",
+ "roleMappingFixedRolesPlaceholderSelect": "Select one or more roles",
+ "roleMappingFixedRolesPlaceholderFreeform": "Type role names (exact match per organization)",
+ "roleMappingFixedRolesDescriptionSameForAll": "Assign the same role set to every auto-provisioned user.",
+ "roleMappingFixedRolesDescriptionDefaultPolicy": "For default policies, type role names that exist in each organization where users are provisioned. Names must match exactly.",
+ "roleMappingClaimPath": "Claim path",
+ "roleMappingClaimPathPlaceholder": "groups",
+ "roleMappingClaimPathDescription": "Path in the token payload that contains source values (for example, groups).",
+ "roleMappingMatchValue": "Match value",
+ "roleMappingAssignRoles": "Assign roles",
+ "roleMappingAddMappingRule": "Add mapping rule",
+ "roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.",
+ "roleMappingMatchValuePlaceholder": "Match value (for example: admin)",
+ "roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)",
+ "roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.",
+ "roleMappingRemoveRule": "Remove",
"idpGoogleConfiguration": "Google Configuration",
"idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials",
"idpGoogleClientIdDescription": "Google OAuth2 Client ID",
@@ -2514,9 +2532,9 @@
"remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?",
"remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.",
"agent": "Agent",
- "personalUseOnly": "Personal Use Only",
- "loginPageLicenseWatermark": "This instance is licensed for personal use only.",
- "instanceIsUnlicensed": "This instance is unlicensed.",
+ "personalUseOnly": "Personal Use Only",
+ "loginPageLicenseWatermark": "This instance is licensed for personal use only.",
+ "instanceIsUnlicensed": "This instance is unlicensed.",
"portRestrictions": "Port Restrictions",
"allPorts": "All",
"custom": "Custom",
@@ -2570,7 +2588,7 @@
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
"forced": "Forced",
"forcedModeDescription": "Always show the maintenance page regardless of backend health. Use this for planned maintenance when you want to prevent all access.",
- "warning:" : "Warning:",
+ "warning:": "Warning:",
"forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.",
"pageTitle": "Page Title",
"pageTitleDescription": "The main heading displayed on the maintenance page",
@@ -2687,5 +2705,6 @@
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
"approvalsEmptyStateButtonText": "Manage Roles",
- "domainErrorTitle": "We are having trouble verifying your domain"
+ "domainErrorTitle": "We are having trouble verifying your domain",
+ "idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the Auto Provision Settings tab."
}
diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx
index d431efa2d..a5ed14a6e 100644
--- a/src/app/admin/idp/[idpId]/general/page.tsx
+++ b/src/app/admin/idp/[idpId]/general/page.tsx
@@ -15,7 +15,8 @@ import {
import { Input } from "@app/components/ui/input";
import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast";
-import { useRouter, useParams, redirect } from "next/navigation";
+import { useRouter, useParams } from "next/navigation";
+import Link from "next/link";
import {
SettingsContainer,
SettingsSection,
@@ -189,15 +190,6 @@ export default function GeneralPage() {
-
-
-
- {t("redirectUrlAbout")}
-
-
- {t("redirectUrlAboutDescription")}
-
-
-
- {t("idpAutoProvisionUsersDescription")}
-
+
+
+ {t(
+ "idpAutoProvisionUsersDescription"
+ )}
+
+ {form.watch("autoProvision") && (
+
+ {t.rich(
+ "idpAdminAutoProvisionPoliciesTabHint",
+ {
+ policiesTabLink: (
+ chunks
+ ) => (
+
+ {chunks}
+
+ )
+ }
+ )}
+
+ )}
+
@@ -375,29 +390,6 @@ export default function GeneralPage() {
className="space-y-4"
id="general-settings-form"
>
-
-
-
- {t("idpJmespathAbout")}
-
-
- {t(
- "idpJmespathAboutDescription"
- )}
-
- {t(
- "idpJmespathAboutDescriptionLink"
- )}{" "}
-
-
-
-
-
void,
+ setFixed: (v: string[]) => void,
+ setClaim: (v: string) => void,
+ setRules: (v: MappingBuilderRule[]) => void,
+ setRaw: (v: string) => void,
+ stored: string | null | undefined
+) {
+ const d = detectRoleMappingConfig(stored);
+ setMode(d.mode);
+ setFixed(d.fixedRoleNames);
+ setClaim(d.mappingBuilder.claimPath);
+ setRules(d.mappingBuilder.rules);
+ setRaw(d.rawExpression);
+}
+
export default function PoliciesPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
- const router = useRouter();
const { idpId } = useParams();
const t = useTranslations();
@@ -88,14 +111,39 @@ export default function PoliciesPage() {
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingPolicy, setEditingPolicy] = useState(null);
+ const [defaultRoleMappingMode, setDefaultRoleMappingMode] =
+ useState("fixedRoles");
+ const [defaultFixedRoleNames, setDefaultFixedRoleNames] = useState<
+ string[]
+ >([]);
+ const [defaultMappingBuilderClaimPath, setDefaultMappingBuilderClaimPath] =
+ useState("groups");
+ const [defaultMappingBuilderRules, setDefaultMappingBuilderRules] =
+ useState([createMappingBuilderRule()]);
+ const [defaultRawRoleExpression, setDefaultRawRoleExpression] =
+ useState("");
+
+ const [policyRoleMappingMode, setPolicyRoleMappingMode] =
+ useState("fixedRoles");
+ const [policyFixedRoleNames, setPolicyFixedRoleNames] = useState(
+ []
+ );
+ const [policyMappingBuilderClaimPath, setPolicyMappingBuilderClaimPath] =
+ useState("groups");
+ const [policyMappingBuilderRules, setPolicyMappingBuilderRules] = useState<
+ MappingBuilderRule[]
+ >([createMappingBuilderRule()]);
+ const [policyRawRoleExpression, setPolicyRawRoleExpression] = useState("");
+ const [policyOrgRoles, setPolicyOrgRoles] = useState<
+ { roleId: number; name: string }[]
+ >([]);
+
const policyFormSchema = z.object({
orgId: z.string().min(1, { message: t("orgRequired") }),
- roleMapping: z.string().optional(),
orgMapping: z.string().optional()
});
const defaultMappingsSchema = z.object({
- defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional()
});
@@ -106,15 +154,15 @@ export default function PoliciesPage() {
resolver: zodResolver(policyFormSchema),
defaultValues: {
orgId: "",
- roleMapping: "",
orgMapping: ""
}
});
+ const policyFormOrgId = form.watch("orgId");
+
const defaultMappingsForm = useForm({
resolver: zodResolver(defaultMappingsSchema),
defaultValues: {
- defaultRoleMapping: "",
defaultOrgMapping: ""
}
});
@@ -127,9 +175,16 @@ export default function PoliciesPage() {
if (res.status === 200) {
const data = res.data.data;
defaultMappingsForm.reset({
- defaultRoleMapping: data.idp.defaultRoleMapping || "",
defaultOrgMapping: data.idp.defaultOrgMapping || ""
});
+ resetRoleMappingStateFromDetected(
+ setDefaultRoleMappingMode,
+ setDefaultFixedRoleNames,
+ setDefaultMappingBuilderClaimPath,
+ setDefaultMappingBuilderRules,
+ setDefaultRawRoleExpression,
+ data.idp.defaultRoleMapping
+ );
}
} catch (e) {
toast({
@@ -184,11 +239,67 @@ export default function PoliciesPage() {
load();
}, [idpId]);
+ useEffect(() => {
+ if (!showAddDialog) {
+ return;
+ }
+
+ const orgId = editingPolicy?.orgId || policyFormOrgId;
+ if (!orgId) {
+ setPolicyOrgRoles([]);
+ return;
+ }
+
+ let cancelled = false;
+ (async () => {
+ const res = await api
+ .get>(`/org/${orgId}/roles`)
+ .catch((e) => {
+ console.error(e);
+ toast({
+ variant: "destructive",
+ title: t("accessRoleErrorFetch"),
+ description: formatAxiosError(
+ e,
+ t("accessRoleErrorFetchDescription")
+ )
+ });
+ return null;
+ });
+ if (!cancelled && res?.status === 200) {
+ setPolicyOrgRoles(res.data.data.roles);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [showAddDialog, editingPolicy?.orgId, policyFormOrgId, api, t]);
+
+ function resetPolicyDialogRoleMappingState() {
+ const d = defaultRoleMappingConfig();
+ setPolicyRoleMappingMode(d.mode);
+ setPolicyFixedRoleNames(d.fixedRoleNames);
+ setPolicyMappingBuilderClaimPath(d.mappingBuilder.claimPath);
+ setPolicyMappingBuilderRules(d.mappingBuilder.rules);
+ setPolicyRawRoleExpression(d.rawExpression);
+ }
+
const onAddPolicy = async (data: PolicyFormValues) => {
+ const roleMappingExpression = compileRoleMappingExpression({
+ mode: policyRoleMappingMode,
+ fixedRoleNames: policyFixedRoleNames,
+ mappingBuilder: {
+ claimPath: policyMappingBuilderClaimPath,
+ rules: policyMappingBuilderRules
+ },
+ rawExpression: policyRawRoleExpression
+ });
+
setAddPolicyLoading(true);
try {
const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, {
- roleMapping: data.roleMapping,
+ roleMapping: roleMappingExpression,
orgMapping: data.orgMapping
});
if (res.status === 201) {
@@ -197,7 +308,7 @@ export default function PoliciesPage() {
name:
organizations.find((org) => org.orgId === data.orgId)
?.name || "",
- roleMapping: data.roleMapping,
+ roleMapping: roleMappingExpression,
orgMapping: data.orgMapping
};
setPolicies([...policies, newPolicy]);
@@ -207,6 +318,7 @@ export default function PoliciesPage() {
});
setShowAddDialog(false);
form.reset();
+ resetPolicyDialogRoleMappingState();
}
} catch (e) {
toast({
@@ -222,12 +334,22 @@ export default function PoliciesPage() {
const onEditPolicy = async (data: PolicyFormValues) => {
if (!editingPolicy) return;
+ const roleMappingExpression = compileRoleMappingExpression({
+ mode: policyRoleMappingMode,
+ fixedRoleNames: policyFixedRoleNames,
+ mappingBuilder: {
+ claimPath: policyMappingBuilderClaimPath,
+ rules: policyMappingBuilderRules
+ },
+ rawExpression: policyRawRoleExpression
+ });
+
setEditPolicyLoading(true);
try {
const res = await api.post(
`/idp/${idpId}/org/${editingPolicy.orgId}`,
{
- roleMapping: data.roleMapping,
+ roleMapping: roleMappingExpression,
orgMapping: data.orgMapping
}
);
@@ -237,7 +359,7 @@ export default function PoliciesPage() {
policy.orgId === editingPolicy.orgId
? {
...policy,
- roleMapping: data.roleMapping,
+ roleMapping: roleMappingExpression,
orgMapping: data.orgMapping
}
: policy
@@ -250,6 +372,7 @@ export default function PoliciesPage() {
setShowAddDialog(false);
setEditingPolicy(null);
form.reset();
+ resetPolicyDialogRoleMappingState();
}
} catch (e) {
toast({
@@ -287,10 +410,20 @@ export default function PoliciesPage() {
};
const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => {
+ const defaultRoleMappingExpression = compileRoleMappingExpression({
+ mode: defaultRoleMappingMode,
+ fixedRoleNames: defaultFixedRoleNames,
+ mappingBuilder: {
+ claimPath: defaultMappingBuilderClaimPath,
+ rules: defaultMappingBuilderRules
+ },
+ rawExpression: defaultRawRoleExpression
+ });
+
setUpdateDefaultMappingsLoading(true);
try {
const res = await api.post(`/idp/${idpId}/oidc`, {
- defaultRoleMapping: data.defaultRoleMapping,
+ defaultRoleMapping: defaultRoleMappingExpression,
defaultOrgMapping: data.defaultOrgMapping
});
if (res.status === 200) {
@@ -317,25 +450,36 @@ export default function PoliciesPage() {
return (
<>
-
-
-
- {t("orgPoliciesAbout")}
-
-
- {/*TODO(vlalx): Validate replacing */}
- {t("orgPoliciesAboutDescription")}{" "}
-
- {t("orgPoliciesAboutDescriptionLink")}
-
-
-
-
+ {
+ loadOrganizations();
+ form.reset({
+ orgId: "",
+ orgMapping: ""
+ });
+ setEditingPolicy(null);
+ resetPolicyDialogRoleMappingState();
+ setShowAddDialog(true);
+ }}
+ onEdit={(policy) => {
+ setEditingPolicy(policy);
+ form.reset({
+ orgId: policy.orgId,
+ orgMapping: policy.orgMapping || ""
+ });
+ resetRoleMappingStateFromDetected(
+ setPolicyRoleMappingMode,
+ setPolicyFixedRoleNames,
+ setPolicyMappingBuilderClaimPath,
+ setPolicyMappingBuilderRules,
+ setPolicyRawRoleExpression,
+ policy.roleMapping
+ );
+ setShowAddDialog(true);
+ }}
+ />
@@ -353,51 +497,58 @@ export default function PoliciesPage() {
onUpdateDefaultMappings
)}
id="policy-default-mappings-form"
- className="space-y-4"
+ className="space-y-6"
>
-
- (
-
-
- {t("defaultMappingsRole")}
-
-
-
-
-
- {t(
- "defaultMappingsRoleDescription"
- )}
-
-
-
- )}
- />
+
- (
-
-
- {t("defaultMappingsOrg")}
-
-
-
-
-
- {t(
- "defaultMappingsOrgDescription"
- )}
-
-
-
- )}
- />
-
+ (
+
+
+ {t("defaultMappingsOrg")}
+
+
+
+
+
+ {t(
+ "defaultMappingsOrgDescription"
+ )}
+
+
+
+ )}
+ />
@@ -411,41 +562,20 @@ export default function PoliciesPage() {
-
- {
- loadOrganizations();
- form.reset({
- orgId: "",
- roleMapping: "",
- orgMapping: ""
- });
- setEditingPolicy(null);
- setShowAddDialog(true);
- }}
- onEdit={(policy) => {
- setEditingPolicy(policy);
- form.reset({
- orgId: policy.orgId,
- roleMapping: policy.roleMapping || "",
- orgMapping: policy.orgMapping || ""
- });
- setShowAddDialog(true);
- }}
- />
{
setShowAddDialog(val);
- setEditingPolicy(null);
- form.reset();
+ if (!val) {
+ setEditingPolicy(null);
+ form.reset();
+ resetPolicyDialogRoleMappingState();
+ }
}}
>
-
+
{editingPolicy
@@ -456,7 +586,7 @@ export default function PoliciesPage() {
{t("orgPolicyConfig")}
-
+
- (
-
-
- {t("roleMappingPathOptional")}
-
-
-
-
-
- {t(
- "defaultMappingsRoleDescription"
- )}
-
-
-
- )}
+
-
-
-
-
- {t("idpOidcConfigureAlert")}
-
-
- {t("idpOidcConfigureAlertDescription")}
-
-
@@ -369,29 +359,6 @@ export default function Page() {
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
-
-
-
- {t("idpJmespathAbout")}
-
-
- {t(
- "idpJmespathAboutDescription"
- )}{" "}
-
- {t(
- "idpJmespathAboutDescriptionLink"
- )}{" "}
-
-
-
-
-
(null);
-
- const roleOptions = useMemo(
- () =>
- roles.map((role) => ({
- id: role.name,
- text: role.name
- })),
- [roles]
- );
return (
@@ -80,261 +60,30 @@ export default function AutoProvisionConfigWidget({
onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/>
-
+
{t("idpAutoProvisionUsersDescription")}
-
+
{autoProvision && (
-
-
-
- {t("roleMapping")}
-
-
- {t("roleMappingDescription")}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {roleMappingMode === "fixedRoles" && (
-
- ({
- id: name,
- text: name
- }))}
- setTags={(nextTags) => {
- const next =
- typeof nextTags === "function"
- ? nextTags(
- fixedRoleNames.map((name) => ({
- id: name,
- text: name
- }))
- )
- : nextTags;
-
- onFixedRoleNamesChange(
- [...new Set(next.map((tag) => tag.text))]
- );
- }}
- activeTagIndex={activeFixedRoleTagIndex}
- setActiveTagIndex={setActiveFixedRoleTagIndex}
- placeholder="Select one or more roles"
- enableAutocomplete={true}
- autocompleteOptions={roleOptions}
- restrictTagsToAutocompleteOptions={true}
- allowDuplicates={false}
- sortTags={true}
- size="sm"
- />
-
- Assign the same role set to every auto-provisioned
- user.
-
-
- )}
-
- {roleMappingMode === "mappingBuilder" && (
-
-
- Claim path
-
- onMappingBuilderClaimPathChange(
- e.target.value
- )
- }
- placeholder="groups"
- />
-
- Path in the token payload that contains source
- values (for example, groups).
-
-
-
-
-
- Match value
- Assign roles
-
-
-
- {mappingBuilderRules.map((rule, index) => (
-
{
- const nextRules =
- mappingBuilderRules.map(
- (row, i) =>
- i === index
- ? nextRule
- : row
- );
- onMappingBuilderRulesChange(
- nextRules
- );
- }}
- onRemove={() => {
- const nextRules =
- mappingBuilderRules.filter(
- (_, i) => i !== index
- );
- onMappingBuilderRulesChange(
- nextRules.length
- ? nextRules
- : [createMappingBuilderRule()]
- );
- }}
- />
- ))}
-
-
-
-
- )}
-
- {roleMappingMode === "rawExpression" && (
-
-
- onRawExpressionChange(e.target.value)
- }
- placeholder={t("roleMappingExpressionPlaceholder")}
- />
-
- Expression must evaluate to a string or string
- array.
-
-
- )}
-
+
)}
);
}
-
-function BuilderRuleRow({
- rule,
- roleOptions,
- onChange,
- onRemove
-}: {
- rule: MappingBuilderRule;
- roleOptions: Tag[];
- onChange: (rule: MappingBuilderRule) => void;
- onRemove: () => void;
-}) {
- const [activeTagIndex, setActiveTagIndex] = useState(null);
-
- return (
-
-
- Match value
-
- onChange({
- ...rule,
- matchValue: e.target.value
- })
- }
- placeholder="Match value (for example: admin)"
- />
-
-
- Assign roles
- ({ id: name, text: name }))}
- setTags={(nextTags) => {
- const next =
- typeof nextTags === "function"
- ? nextTags(
- rule.roleNames.map((name) => ({
- id: name,
- text: name
- }))
- )
- : nextTags;
- onChange({
- ...rule,
- roleNames: [...new Set(next.map((tag) => tag.text))]
- });
- }}
- activeTagIndex={activeTagIndex}
- setActiveTagIndex={setActiveTagIndex}
- placeholder="Assign roles"
- enableAutocomplete={true}
- autocompleteOptions={roleOptions}
- restrictTagsToAutocompleteOptions={true}
- allowDuplicates={false}
- sortTags={true}
- size="sm"
- />
-
-
-
-
-
- );
-}
diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx
new file mode 100644
index 000000000..4fe1a037b
--- /dev/null
+++ b/src/components/RoleMappingConfigFields.tsx
@@ -0,0 +1,366 @@
+"use client";
+
+import { FormLabel, FormDescription } from "@app/components/ui/form";
+import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
+import { Button } from "@app/components/ui/button";
+import { Input } from "@app/components/ui/input";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { Tag, TagInput } from "@app/components/tags/tag-input";
+import {
+ createMappingBuilderRule,
+ MappingBuilderRule,
+ RoleMappingMode
+} from "@app/lib/idpRoleMapping";
+
+export type RoleMappingRoleOption = {
+ roleId: number;
+ name: string;
+};
+
+export type RoleMappingConfigFieldsProps = {
+ roleMappingMode: RoleMappingMode;
+ onRoleMappingModeChange: (mode: RoleMappingMode) => void;
+ roles: RoleMappingRoleOption[];
+ fixedRoleNames: string[];
+ onFixedRoleNamesChange: (roleNames: string[]) => void;
+ mappingBuilderClaimPath: string;
+ onMappingBuilderClaimPathChange: (claimPath: string) => void;
+ mappingBuilderRules: MappingBuilderRule[];
+ onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
+ rawExpression: string;
+ onRawExpressionChange: (expression: string) => void;
+ /** Unique prefix for radio `id`/`htmlFor` when multiple instances exist on one page. */
+ fieldIdPrefix?: string;
+ /** When true, show extra hint for global default policies (no org role list). */
+ showFreeformRoleNamesHint?: boolean;
+};
+
+export default function RoleMappingConfigFields({
+ roleMappingMode,
+ onRoleMappingModeChange,
+ roles,
+ fixedRoleNames,
+ onFixedRoleNamesChange,
+ mappingBuilderClaimPath,
+ onMappingBuilderClaimPathChange,
+ mappingBuilderRules,
+ onMappingBuilderRulesChange,
+ rawExpression,
+ onRawExpressionChange,
+ fieldIdPrefix = "role-mapping",
+ showFreeformRoleNamesHint = false
+}: RoleMappingConfigFieldsProps) {
+ const t = useTranslations();
+ const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
+ number | null
+ >(null);
+
+ const restrictToOrgRoles = roles.length > 0;
+
+ const roleOptions = useMemo(
+ () =>
+ roles.map((role) => ({
+ id: role.name,
+ text: role.name
+ })),
+ [roles]
+ );
+
+ const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
+ const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
+ const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
+
+ /** Same template on header + rows so 1fr/1.75fr columns line up (auto third col differs per row otherwise). */
+ const mappingRulesGridClass =
+ "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3";
+
+ return (
+
+
+
{t("roleMapping")}
+
+ {t("roleMappingDescription")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {roleMappingMode === "fixedRoles" && (
+
+ ({
+ id: name,
+ text: name
+ }))}
+ setTags={(nextTags) => {
+ const next =
+ typeof nextTags === "function"
+ ? nextTags(
+ fixedRoleNames.map((name) => ({
+ id: name,
+ text: name
+ }))
+ )
+ : nextTags;
+
+ onFixedRoleNamesChange([
+ ...new Set(next.map((tag) => tag.text))
+ ]);
+ }}
+ activeTagIndex={activeFixedRoleTagIndex}
+ setActiveTagIndex={setActiveFixedRoleTagIndex}
+ placeholder={
+ restrictToOrgRoles
+ ? t("roleMappingFixedRolesPlaceholderSelect")
+ : t("roleMappingFixedRolesPlaceholderFreeform")
+ }
+ enableAutocomplete={restrictToOrgRoles}
+ autocompleteOptions={roleOptions}
+ restrictTagsToAutocompleteOptions={restrictToOrgRoles}
+ allowDuplicates={false}
+ sortTags={true}
+ size="sm"
+ />
+
+ {showFreeformRoleNamesHint
+ ? t("roleMappingFixedRolesDescriptionDefaultPolicy")
+ : t("roleMappingFixedRolesDescriptionSameForAll")}
+
+
+ )}
+
+ {roleMappingMode === "mappingBuilder" && (
+
+
+ {t("roleMappingClaimPath")}
+
+ onMappingBuilderClaimPathChange(e.target.value)
+ }
+ placeholder={t("roleMappingClaimPathPlaceholder")}
+ />
+
+ {t("roleMappingClaimPathDescription")}
+
+
+
+
+
+
+ {t("roleMappingMatchValue")}
+
+
+ {t("roleMappingAssignRoles")}
+
+
+
+
+ {mappingBuilderRules.map((rule, index) => (
+
{
+ const nextRules = mappingBuilderRules.map(
+ (row, i) =>
+ i === index ? nextRule : row
+ );
+ onMappingBuilderRulesChange(nextRules);
+ }}
+ onRemove={() => {
+ const nextRules =
+ mappingBuilderRules.filter(
+ (_, i) => i !== index
+ );
+ onMappingBuilderRulesChange(
+ nextRules.length
+ ? nextRules
+ : [createMappingBuilderRule()]
+ );
+ }}
+ />
+ ))}
+
+
+
+
+ )}
+
+ {roleMappingMode === "rawExpression" && (
+
+ onRawExpressionChange(e.target.value)}
+ placeholder={t("roleMappingExpressionPlaceholder")}
+ />
+
+ {t("roleMappingRawExpressionResultDescription")}
+
+
+ )}
+
+ );
+}
+
+function BuilderRuleRow({
+ rule,
+ roleOptions,
+ restrictToOrgRoles,
+ showFreeformRoleNamesHint,
+ fieldIdPrefix,
+ mappingRulesGridClass,
+ onChange,
+ onRemove
+}: {
+ rule: MappingBuilderRule;
+ roleOptions: Tag[];
+ restrictToOrgRoles: boolean;
+ showFreeformRoleNamesHint: boolean;
+ fieldIdPrefix: string;
+ mappingRulesGridClass: string;
+ onChange: (rule: MappingBuilderRule) => void;
+ onRemove: () => void;
+}) {
+ const t = useTranslations();
+ const [activeTagIndex, setActiveTagIndex] = useState(null);
+
+ return (
+
+
+
+ {t("roleMappingMatchValue")}
+
+
+ onChange({
+ ...rule,
+ matchValue: e.target.value
+ })
+ }
+ placeholder={t("roleMappingMatchValuePlaceholder")}
+ />
+
+
+
+ {t("roleMappingAssignRoles")}
+
+
+ ({
+ id: name,
+ text: name
+ }))}
+ setTags={(nextTags) => {
+ const next =
+ typeof nextTags === "function"
+ ? nextTags(
+ rule.roleNames.map((name) => ({
+ id: name,
+ text: name
+ }))
+ )
+ : nextTags;
+ onChange({
+ ...rule,
+ roleNames: [
+ ...new Set(next.map((tag) => tag.text))
+ ]
+ });
+ }}
+ activeTagIndex={activeTagIndex}
+ setActiveTagIndex={setActiveTagIndex}
+ placeholder={
+ restrictToOrgRoles
+ ? t("roleMappingAssignRoles")
+ : t("roleMappingAssignRolesPlaceholderFreeform")
+ }
+ enableAutocomplete={restrictToOrgRoles}
+ autocompleteOptions={roleOptions}
+ restrictTagsToAutocompleteOptions={restrictToOrgRoles}
+ allowDuplicates={false}
+ sortTags={true}
+ size="sm"
+ styleClasses={{
+ inlineTagsContainer: "min-w-0 max-w-full"
+ }}
+ />
+
+ {showFreeformRoleNamesHint && (
+
+ {t("roleMappingBuilderFreeformRowHint")}
+
+ )}
+
+
+
+
+
+ );
+}
From 7bcb852dba81b20d3d38469c88c8cad1dba8495d Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Fri, 27 Mar 2026 18:10:19 -0700
Subject: [PATCH 08/19] add google and azure templates to global idp
---
messages/en-US.json | 16 +-
server/routers/idp/createOidcIdp.ts | 9 +-
server/routers/idp/updateOidcIdp.ts | 9 +-
.../(private)/idp/[idpId]/general/page.tsx | 33 -
.../settings/(private)/idp/create/page.tsx | 124 +---
src/app/admin/idp/[idpId]/general/page.tsx | 638 +++++++++++++-----
src/app/admin/idp/[idpId]/policies/page.tsx | 2 +-
src/app/admin/idp/create/page.tsx | 282 ++++++--
src/app/admin/layout.tsx | 13 +-
src/components/RoleMappingConfigFields.tsx | 2 +-
.../idp/OidcIdpProviderTypeSelect.tsx | 75 ++
src/lib/idp/oidcIdpProviderDefaults.ts | 46 ++
12 files changed, 870 insertions(+), 379 deletions(-)
create mode 100644 src/components/idp/OidcIdpProviderTypeSelect.tsx
create mode 100644 src/lib/idp/oidcIdpProviderDefaults.ts
diff --git a/messages/en-US.json b/messages/en-US.json
index 7d00c8105..505378b7f 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -890,7 +890,7 @@
"defaultMappingsRole": "Default Role Mapping",
"defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.",
"defaultMappingsOrg": "Default Organization Mapping",
- "defaultMappingsOrgDescription": "This expression must return the org ID or true for the user to be allowed to access the organization.",
+ "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.",
"defaultMappingsSubmit": "Save Default Mappings",
"orgPoliciesEdit": "Edit Organization Policy",
"org": "Organization",
@@ -1942,19 +1942,19 @@
"invalidValue": "Invalid value",
"idpTypeLabel": "Identity Provider Type",
"roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'",
- "roleMappingModeFixedRoles": "Fixed roles",
- "roleMappingModeMappingBuilder": "Mapping builder",
- "roleMappingModeRawExpression": "Raw expression",
+ "roleMappingModeFixedRoles": "Fixed Roles",
+ "roleMappingModeMappingBuilder": "Mapping Builder",
+ "roleMappingModeRawExpression": "Raw Expression",
"roleMappingFixedRolesPlaceholderSelect": "Select one or more roles",
"roleMappingFixedRolesPlaceholderFreeform": "Type role names (exact match per organization)",
"roleMappingFixedRolesDescriptionSameForAll": "Assign the same role set to every auto-provisioned user.",
"roleMappingFixedRolesDescriptionDefaultPolicy": "For default policies, type role names that exist in each organization where users are provisioned. Names must match exactly.",
- "roleMappingClaimPath": "Claim path",
+ "roleMappingClaimPath": "Claim Path",
"roleMappingClaimPathPlaceholder": "groups",
"roleMappingClaimPathDescription": "Path in the token payload that contains source values (for example, groups).",
- "roleMappingMatchValue": "Match value",
- "roleMappingAssignRoles": "Assign roles",
- "roleMappingAddMappingRule": "Add mapping rule",
+ "roleMappingMatchValue": "Match Value",
+ "roleMappingAssignRoles": "Assign Roles",
+ "roleMappingAddMappingRule": "Add Mapping Rule",
"roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.",
"roleMappingMatchValuePlaceholder": "Match value (for example: admin)",
"roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)",
diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts
index 5b53f6820..0f0cc0cce 100644
--- a/server/routers/idp/createOidcIdp.ts
+++ b/server/routers/idp/createOidcIdp.ts
@@ -25,7 +25,8 @@ const bodySchema = z.strictObject({
namePath: z.string().optional(),
scopes: z.string().nonempty(),
autoProvision: z.boolean().optional(),
- tags: z.string().optional()
+ tags: z.string().optional(),
+ variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc")
});
export type CreateIdpResponse = {
@@ -77,7 +78,8 @@ export async function createOidcIdp(
namePath,
name,
autoProvision,
- tags
+ tags,
+ variant
} = parsedBody.data;
if (
@@ -121,7 +123,8 @@ export async function createOidcIdp(
scopes,
identifierPath,
emailPath,
- namePath
+ namePath,
+ variant
});
});
diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts
index fe32a8b08..905b32013 100644
--- a/server/routers/idp/updateOidcIdp.ts
+++ b/server/routers/idp/updateOidcIdp.ts
@@ -31,7 +31,8 @@ const bodySchema = z.strictObject({
autoProvision: z.boolean().optional(),
defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional(),
- tags: z.string().optional()
+ tags: z.string().optional(),
+ variant: z.enum(["oidc", "google", "azure"]).optional()
});
export type UpdateIdpResponse = {
@@ -96,7 +97,8 @@ export async function updateOidcIdp(
autoProvision,
defaultRoleMapping,
defaultOrgMapping,
- tags
+ tags,
+ variant
} = parsedBody.data;
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
@@ -159,7 +161,8 @@ export async function updateOidcIdp(
scopes,
identifierPath,
emailPath,
- namePath
+ namePath,
+ variant
};
keysToUpdate = Object.keys(configData).filter(
diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
index 9754b07e5..37cf400a5 100644
--- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
+++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
@@ -448,16 +448,6 @@ export default function GeneralPage() {
-
-
-
- {t("redirectUrlAbout")}
-
-
- {t("redirectUrlAboutDescription")}
-
-
-
{/* IDP Type Indicator */}
@@ -843,29 +833,6 @@ export default function GeneralPage() {
className="space-y-4"
id="general-settings-form"
>
-
-
-
- {t("idpJmespathAbout")}
-
-
- {t(
- "idpJmespathAboutDescription"
- )}{" "}
-
- {t(
- "idpJmespathAboutDescriptionLink"
- )}{" "}
-
-
-
-
-
;
- interface ProviderTypeOption {
- id: "oidc" | "google" | "azure";
- title: string;
- description: string;
- icon?: React.ReactNode;
- }
-
- const providerTypes: ReadonlyArray = [
- {
- id: "oidc",
- title: "OAuth2/OIDC",
- description: t("idpOidcDescription")
- },
- {
- id: "google",
- title: t("idpGoogleTitle"),
- description: t("idpGoogleDescription"),
- icon: (
-
- )
- },
- {
- id: "azure",
- title: t("idpAzureTitle"),
- description: t("idpAzureDescription"),
- icon: (
-
- )
- }
- ];
-
const form = useForm({
resolver: zodResolver(createIdpFormSchema),
defaultValues: {
@@ -186,47 +142,6 @@ export default function Page() {
fetchRoles();
}, []);
- // Handle provider type changes and set defaults
- const handleProviderChange = (value: "oidc" | "google" | "azure") => {
- form.setValue("type", value);
-
- if (value === "google") {
- // Set Google defaults
- form.setValue(
- "authUrl",
- "https://accounts.google.com/o/oauth2/v2/auth"
- );
- form.setValue("tokenUrl", "https://oauth2.googleapis.com/token");
- form.setValue("identifierPath", "email");
- form.setValue("emailPath", "email");
- form.setValue("namePath", "name");
- form.setValue("scopes", "openid profile email");
- } else if (value === "azure") {
- // Set Azure Entra ID defaults (URLs will be constructed dynamically)
- form.setValue(
- "authUrl",
- "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize"
- );
- form.setValue(
- "tokenUrl",
- "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token"
- );
- form.setValue("identifierPath", "email");
- form.setValue("emailPath", "email");
- form.setValue("namePath", "name");
- form.setValue("scopes", "openid profile email");
- form.setValue("tenantId", "");
- } else {
- // Reset to OIDC defaults
- form.setValue("authUrl", "");
- form.setValue("tokenUrl", "");
- form.setValue("identifierPath", "sub");
- form.setValue("namePath", "name");
- form.setValue("emailPath", "email");
- form.setValue("scopes", "openid profile email");
- }
- };
-
async function onSubmit(data: CreateIdpFormValues) {
setCreateLoading(true);
@@ -304,6 +219,7 @@ export default function Page() {
}
const disabled = !isPaidUser(tierMatrix.orgOidc);
+ const templatesPaid = isPaidUser(tierMatrix.orgOidc);
return (
<>
@@ -336,23 +252,13 @@ export default function Page() {
-
-
-
- {t("idpType")}
-
-
-
{
- handleProviderChange(
- value as "oidc" | "google" | "azure"
- );
- }}
- cols={3}
- />
-
+ {
+ applyOidcIdpProviderType(form.setValue, next);
+ }}
+ />
-
-
-
-
- {t("idpOidcConfigureAlert")}
-
-
- {t("idpOidcConfigureAlertDescription")}
-
-
diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx
index a5ed14a6e..d02925976 100644
--- a/src/app/admin/idp/[idpId]/general/page.tsx
+++ b/src/app/admin/idp/[idpId]/general/page.tsx
@@ -25,7 +25,6 @@ import {
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
- SettingsSectionFooter,
SettingsSectionGrid
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
@@ -33,8 +32,6 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState, useEffect } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
-import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
-import { InfoIcon, ExternalLink } from "lucide-react";
import {
InfoSection,
InfoSectionContent,
@@ -42,8 +39,7 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
-import { Badge } from "@app/components/ui/badge";
-import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
+import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { useTranslations } from "next-intl";
export default function GeneralPage() {
@@ -53,12 +49,12 @@ export default function GeneralPage() {
const { idpId } = useParams();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
- const { isUnlocked } = useLicenseStatusContext();
+ const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const t = useTranslations();
- const GeneralFormSchema = z.object({
+ const OidcFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
@@ -73,10 +69,46 @@ export default function GeneralPage() {
autoProvision: z.boolean().default(false)
});
- type GeneralFormValues = z.infer;
+ const GoogleFormSchema = z.object({
+ name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
+ clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
+ clientSecret: z
+ .string()
+ .min(1, { message: t("idpClientSecretRequired") }),
+ autoProvision: z.boolean().default(false)
+ });
- const form = useForm({
- resolver: zodResolver(GeneralFormSchema),
+ const AzureFormSchema = z.object({
+ name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
+ clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
+ clientSecret: z
+ .string()
+ .min(1, { message: t("idpClientSecretRequired") }),
+ tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }),
+ autoProvision: z.boolean().default(false)
+ });
+
+ type OidcFormValues = z.infer;
+ type GoogleFormValues = z.infer;
+ type AzureFormValues = z.infer;
+ type GeneralFormValues =
+ | OidcFormValues
+ | GoogleFormValues
+ | AzureFormValues;
+
+ const getFormSchema = () => {
+ switch (variant) {
+ case "google":
+ return GoogleFormSchema;
+ case "azure":
+ return AzureFormSchema;
+ default:
+ return OidcFormSchema;
+ }
+ };
+
+ const form = useForm({
+ resolver: zodResolver(getFormSchema()) as never,
defaultValues: {
name: "",
clientId: "",
@@ -87,28 +119,60 @@ export default function GeneralPage() {
emailPath: "email",
namePath: "name",
scopes: "openid profile email",
- autoProvision: true
+ autoProvision: true,
+ tenantId: ""
}
});
+ useEffect(() => {
+ form.clearErrors();
+ }, [variant, form]);
+
useEffect(() => {
const loadIdp = async () => {
try {
const res = await api.get(`/idp/${idpId}`);
if (res.status === 200) {
const data = res.data.data;
- form.reset({
+ const idpVariant =
+ (data.idpOidcConfig?.variant as
+ | "oidc"
+ | "google"
+ | "azure") || "oidc";
+ setVariant(idpVariant);
+
+ let tenantId = "";
+ if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) {
+ const tenantMatch = data.idpOidcConfig.authUrl.match(
+ /login\.microsoftonline\.com\/([^/]+)\/oauth2/
+ );
+ if (tenantMatch) {
+ tenantId = tenantMatch[1];
+ }
+ }
+
+ const formData: Record = {
name: data.idp.name,
clientId: data.idpOidcConfig.clientId,
clientSecret: data.idpOidcConfig.clientSecret,
- authUrl: data.idpOidcConfig.authUrl,
- tokenUrl: data.idpOidcConfig.tokenUrl,
- identifierPath: data.idpOidcConfig.identifierPath,
- emailPath: data.idpOidcConfig.emailPath,
- namePath: data.idpOidcConfig.namePath,
- scopes: data.idpOidcConfig.scopes,
autoProvision: data.idp.autoProvision
- });
+ };
+
+ if (idpVariant === "oidc") {
+ formData.authUrl = data.idpOidcConfig.authUrl;
+ formData.tokenUrl = data.idpOidcConfig.tokenUrl;
+ formData.identifierPath =
+ data.idpOidcConfig.identifierPath;
+ formData.emailPath =
+ data.idpOidcConfig.emailPath ?? undefined;
+ formData.namePath =
+ data.idpOidcConfig.namePath ?? undefined;
+ formData.scopes = data.idpOidcConfig.scopes;
+ } else if (idpVariant === "azure") {
+ formData.tenantId = tenantId;
+ }
+
+ form.reset(formData as GeneralFormValues);
}
} catch (e) {
toast({
@@ -123,25 +187,76 @@ export default function GeneralPage() {
};
loadIdp();
- }, [idpId, api, form, router]);
+ }, [idpId]);
async function onSubmit(data: GeneralFormValues) {
setLoading(true);
try {
- const payload = {
+ const schema = getFormSchema();
+ const validationResult = schema.safeParse(data);
+
+ if (!validationResult.success) {
+ const errors = validationResult.error.flatten().fieldErrors;
+ Object.keys(errors).forEach((key) => {
+ const fieldName = key as keyof GeneralFormValues;
+ const errorMessage =
+ (errors as Record)[
+ key
+ ]?.[0] || t("invalidValue");
+ form.setError(fieldName, {
+ type: "manual",
+ message: errorMessage
+ });
+ });
+ setLoading(false);
+ return;
+ }
+
+ let payload: Record = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
- authUrl: data.authUrl,
- tokenUrl: data.tokenUrl,
- identifierPath: data.identifierPath,
- emailPath: data.emailPath,
- namePath: data.namePath,
autoProvision: data.autoProvision,
- scopes: data.scopes
+ variant
};
+ if (variant === "oidc") {
+ const oidcData = data as OidcFormValues;
+ payload = {
+ ...payload,
+ authUrl: oidcData.authUrl,
+ tokenUrl: oidcData.tokenUrl,
+ identifierPath: oidcData.identifierPath,
+ emailPath: oidcData.emailPath ?? "",
+ namePath: oidcData.namePath ?? "",
+ scopes: oidcData.scopes
+ };
+ } else if (variant === "azure") {
+ const azureData = data as AzureFormValues;
+ const authUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/authorize`;
+ const tokenUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/token`;
+ payload = {
+ ...payload,
+ authUrl,
+ tokenUrl,
+ identifierPath: "email",
+ emailPath: "email",
+ namePath: "name",
+ scopes: "openid profile email"
+ };
+ } else if (variant === "google") {
+ payload = {
+ ...payload,
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
+ tokenUrl: "https://oauth2.googleapis.com/token",
+ identifierPath: "email",
+ emailPath: "email",
+ namePath: "name",
+ scopes: "openid profile email"
+ };
+ }
+
const res = await api.post(`/idp/${idpId}/oidc`, payload);
if (res.status === 200) {
@@ -190,6 +305,13 @@ export default function GeneralPage() {
+
+
+ {t("idpTypeLabel")}:
+
+
+
+
-
+
+
+
+ {t("idpAutoProvisionUsers")}
+
+
+ {t("idpAutoProvisionUsersDescription")}
+
+
+
+
+
+
+
+
+ {variant === "google" && (
- {t("idpOidcConfigure")}
+ {t("idpGoogleConfiguration")}
- {t("idpOidcConfigureDescription")}
+ {t("idpGoogleConfigurationDescription")}
@@ -294,7 +434,7 @@ export default function GeneralPage() {
{t(
- "idpClientIdDescription"
+ "idpGoogleClientIdDescription"
)}
@@ -318,49 +458,7 @@ export default function GeneralPage() {
{t(
- "idpClientSecretDescription"
- )}
-
-
-
- )}
- />
-
- (
-
-
- {t("idpAuthUrl")}
-
-
-
-
-
- {t(
- "idpAuthUrlDescription"
- )}
-
-
-
- )}
- />
-
- (
-
-
- {t("idpTokenUrl")}
-
-
-
-
-
- {t(
- "idpTokenUrlDescription"
+ "idpGoogleClientSecretDescription"
)}
@@ -372,14 +470,16 @@ export default function GeneralPage() {
+ )}
+ {variant === "azure" && (
- {t("idpToken")}
+ {t("idpAzureConfiguration")}
- {t("idpTokenDescription")}
+ {t("idpAzureConfigurationDescription")}
@@ -392,18 +492,18 @@ export default function GeneralPage() {
>
(
- {t("idpJmespathLabel")}
+ {t("idpTenantId")}
{t(
- "idpJmespathLabelDescription"
+ "idpAzureTenantIdDescription"
)}
@@ -413,20 +513,18 @@ export default function GeneralPage() {
(
- {t(
- "idpJmespathEmailPathOptional"
- )}
+ {t("idpClientId")}
{t(
- "idpJmespathEmailPathOptionalDescription"
+ "idpAzureClientIdDescription"
)}
@@ -436,43 +534,21 @@ export default function GeneralPage() {
(
- {t(
- "idpJmespathNamePathOptional"
- )}
+ {t("idpClientSecret")}
-
+
{t(
- "idpJmespathNamePathOptionalDescription"
- )}
-
-
-
- )}
- />
-
- (
-
-
- {t(
- "idpOidcConfigureScopes"
- )}
-
-
-
-
-
- {t(
- "idpOidcConfigureScopesDescription"
+ "idpAzureClientSecretDescription"
)}
@@ -484,15 +560,263 @@ export default function GeneralPage() {
-
+ )}
+
+ {variant === "oidc" && (
+
+
+
+
+ {t("idpOidcConfigure")}
+
+
+ {t("idpOidcConfigureDescription")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("idpToken")}
+
+
+ {t("idpTokenDescription")}
+
+
+
+
+
+
+
+
+
+
+ )}
diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx
index 086074e2d..57ee3cf7b 100644
--- a/src/app/admin/idp/[idpId]/policies/page.tsx
+++ b/src/app/admin/idp/[idpId]/policies/page.tsx
@@ -575,7 +575,7 @@ export default function PoliciesPage() {
}
}}
>
-
+
{editingPolicy
diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx
index 91b55da23..40d4a3b32 100644
--- a/src/app/admin/idp/create/page.tsx
+++ b/src/app/admin/idp/create/page.tsx
@@ -1,5 +1,7 @@
"use client";
+import { OidcIdpProviderTypeSelect } from "@app/components/idp/OidcIdpProviderTypeSelect";
+import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsContainer,
SettingsSection,
@@ -20,70 +22,63 @@ import {
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
-import { z } from "zod";
-import { createElement, useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Input } from "@app/components/ui/input";
+import { SwitchInput } from "@app/components/SwitchInput";
+import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
-import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { Input } from "@app/components/ui/input";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
-import { useRouter } from "next/navigation";
-import { Checkbox } from "@app/components/ui/checkbox";
-import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
-import { InfoIcon, ExternalLink } from "lucide-react";
-import { StrategySelect } from "@app/components/StrategySelect";
-import { SwitchInput } from "@app/components/SwitchInput";
-import { Badge } from "@app/components/ui/badge";
-import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [createLoading, setCreateLoading] = useState(false);
- const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
+ const { isPaidUser } = usePaidStatus();
+ const templatesPaid = isPaidUser(tierMatrix.orgOidc);
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
- type: z.enum(["oidc"]),
+ type: z.enum(["oidc", "google", "azure"]),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
- authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }),
- tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }),
- identifierPath: z.string().min(1, { message: t("idpPathRequired") }),
+ authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }).optional(),
+ tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }).optional(),
+ identifierPath: z
+ .string()
+ .min(1, { message: t("idpPathRequired") })
+ .optional(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
- scopes: z.string().min(1, { message: t("idpScopeRequired") }),
+ scopes: z
+ .string()
+ .min(1, { message: t("idpScopeRequired") })
+ .optional(),
+ tenantId: z.string().optional(),
autoProvision: z.boolean().default(false)
});
type CreateIdpFormValues = z.infer;
- interface ProviderTypeOption {
- id: "oidc";
- title: string;
- description: string;
- }
-
- const providerTypes: ReadonlyArray = [
- {
- id: "oidc",
- title: "OAuth2/OIDC",
- description: t("idpOidcDescription")
- }
- ];
-
const form = useForm({
resolver: zodResolver(createIdpFormSchema),
defaultValues: {
name: "",
- type: "oidc",
+ type: "oidc" as const,
clientId: "",
clientSecret: "",
authUrl: "",
@@ -92,25 +87,46 @@ export default function Page() {
namePath: "name",
emailPath: "email",
scopes: "openid profile email",
+ tenantId: "",
autoProvision: false
}
});
+ const watchedType = form.watch("type");
+
+ useEffect(() => {
+ if (
+ !templatesPaid &&
+ (watchedType === "google" || watchedType === "azure")
+ ) {
+ applyOidcIdpProviderType(form.setValue, "oidc");
+ }
+ }, [templatesPaid, watchedType, form.setValue]);
+
async function onSubmit(data: CreateIdpFormValues) {
setCreateLoading(true);
try {
+ let authUrl = data.authUrl;
+ let tokenUrl = data.tokenUrl;
+
+ if (data.type === "azure" && data.tenantId) {
+ authUrl = authUrl?.replace("{{TENANT_ID}}", data.tenantId);
+ tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId);
+ }
+
const payload = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
- authUrl: data.authUrl,
- tokenUrl: data.tokenUrl,
+ authUrl: authUrl,
+ tokenUrl: tokenUrl,
identifierPath: data.identifierPath,
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
- scopes: data.scopes
+ scopes: data.scopes,
+ variant: data.type
};
const res = await api.put("/idp/oidc", payload);
@@ -150,6 +166,10 @@ export default function Page() {
+ {!templatesPaid ? (
+
+ ) : null}
+
@@ -161,6 +181,14 @@ export default function Page() {
+ {
+ applyOidcIdpProviderType(form.setValue, next);
+ }}
+ />
+
-
- {/* */}
- {/*
*/}
- {/* */}
- {/* {t("idpType")} */}
- {/* */}
- {/*
*/}
- {/* */}
- {/*
{ */}
- {/* form.setValue("type", value as "oidc"); */}
- {/* }} */}
- {/* cols={3} */}
- {/* /> */}
- {/* */}
- {form.watch("type") === "oidc" && (
+ {watchedType === "google" && (
+
+
+
+ {t("idpGoogleConfigurationTitle")}
+
+
+ {t("idpGoogleConfigurationDescription")}
+
+
+
+
+
+
+
+
+
+ )}
+
+ {watchedType === "azure" && (
+
+
+
+ {t("idpAzureConfigurationTitle")}
+
+
+ {t("idpAzureConfigurationDescription")}
+
+
+
+
+
+
+
+
+
+ )}
+
+ {watchedType === "oidc" && (
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index 44d85b99e..5f35ee4cd 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -12,6 +12,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { Layout } from "@app/components/Layout";
import { adminNavSections } from "../navigation";
import { pullEnv } from "@app/lib/pullEnv";
+import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider";
export const dynamic = "force-dynamic";
@@ -51,9 +52,15 @@ export default async function AdminLayout(props: LayoutProps) {
return (
-
- {props.children}
-
+
+
+ {props.children}
+
+
);
}
diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx
index 4fe1a037b..deb2cc9ac 100644
--- a/src/components/RoleMappingConfigFields.tsx
+++ b/src/components/RoleMappingConfigFields.tsx
@@ -166,7 +166,7 @@ export default function RoleMappingConfigFields({
)}
{roleMappingMode === "mappingBuilder" && (
-
+
{t("roleMappingClaimPath")}
void;
+ templatesPaid: boolean;
+};
+
+export function OidcIdpProviderTypeSelect({
+ value,
+ onTypeChange,
+ templatesPaid
+}: Props) {
+ const t = useTranslations();
+
+ const options: ReadonlyArray
> = [
+ {
+ id: "oidc",
+ title: "OAuth2/OIDC",
+ description: t("idpOidcDescription")
+ },
+ {
+ id: "google",
+ title: t("idpGoogleTitle"),
+ description: t("idpGoogleDescription"),
+ disabled: !templatesPaid,
+ icon: (
+
+ )
+ },
+ {
+ id: "azure",
+ title: t("idpAzureTitle"),
+ description: t("idpAzureDescription"),
+ disabled: !templatesPaid,
+ icon: (
+
+ )
+ }
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/lib/idp/oidcIdpProviderDefaults.ts b/src/lib/idp/oidcIdpProviderDefaults.ts
new file mode 100644
index 000000000..3608c6882
--- /dev/null
+++ b/src/lib/idp/oidcIdpProviderDefaults.ts
@@ -0,0 +1,46 @@
+import type { FieldValues, UseFormSetValue } from "react-hook-form";
+
+export type IdpOidcProviderType = "oidc" | "google" | "azure";
+
+export function applyOidcIdpProviderType(
+ setValue: UseFormSetValue,
+ provider: IdpOidcProviderType
+): void {
+ setValue("type" as never, provider as never);
+
+ if (provider === "google") {
+ setValue(
+ "authUrl" as never,
+ "https://accounts.google.com/o/oauth2/v2/auth" as never
+ );
+ setValue(
+ "tokenUrl" as never,
+ "https://oauth2.googleapis.com/token" as never
+ );
+ setValue("identifierPath" as never, "email" as never);
+ setValue("emailPath" as never, "email" as never);
+ setValue("namePath" as never, "name" as never);
+ setValue("scopes" as never, "openid profile email" as never);
+ } else if (provider === "azure") {
+ setValue(
+ "authUrl" as never,
+ "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" as never
+ );
+ setValue(
+ "tokenUrl" as never,
+ "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" as never
+ );
+ setValue("identifierPath" as never, "email" as never);
+ setValue("emailPath" as never, "email" as never);
+ setValue("namePath" as never, "name" as never);
+ setValue("scopes" as never, "openid profile email" as never);
+ setValue("tenantId" as never, "" as never);
+ } else {
+ setValue("authUrl" as never, "" as never);
+ setValue("tokenUrl" as never, "" as never);
+ setValue("identifierPath" as never, "sub" as never);
+ setValue("namePath" as never, "name" as never);
+ setValue("emailPath" as never, "email" as never);
+ setValue("scopes" as never, "openid profile email" as never);
+ }
+}
From c6f269b3fa4a5c9b11c41e52942e4fb75b132179 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Sat, 28 Mar 2026 17:29:01 -0700
Subject: [PATCH 09/19] set roles 1:1 on auto provision
---
server/routers/idp/validateOidcCallback.ts | 34 ++++++++++------------
1 file changed, 16 insertions(+), 18 deletions(-)
diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts
index 86545269b..7f39aa38d 100644
--- a/server/routers/idp/validateOidcCallback.ts
+++ b/server/routers/idp/validateOidcCallback.ts
@@ -579,30 +579,28 @@ export async function validateOidcCallback(
}
}
- // Ensure IDP-provided role exists for existing auto-provisioned orgs (add only; never delete other roles)
- const userRolesInOrgs = await trx
- .select()
- .from(userOrgRoles)
- .where(eq(userOrgRoles.userId, userId!));
+ // Sync roles 1:1 with IdP policy for existing auto-provisioned orgs
for (const currentOrg of autoProvisionedOrgs) {
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
if (!newRole) continue;
- const currentRolesInOrg = userRolesInOrgs.filter(
- (r) => r.orgId === currentOrg.orgId
- );
- for (const roleId of newRole.roleIds) {
- const hasIdpRole = currentRolesInOrg.some(
- (r) => r.roleId === roleId
+
+ await trx
+ .delete(userOrgRoles)
+ .where(
+ and(
+ eq(userOrgRoles.userId, userId!),
+ eq(userOrgRoles.orgId, currentOrg.orgId)
+ )
);
- if (!hasIdpRole) {
- await trx.insert(userOrgRoles).values({
- userId: userId!,
- orgId: currentOrg.orgId,
- roleId
- });
- }
+
+ for (const roleId of newRole.roleIds) {
+ await trx.insert(userOrgRoles).values({
+ userId: userId!,
+ orgId: currentOrg.orgId,
+ roleId
+ });
}
}
From 6ab05551487a4ff5f5f2cec85177be512ddf0c69 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Sat, 28 Mar 2026 18:09:36 -0700
Subject: [PATCH 10/19] respect full rbac feature in auto provisioning
---
messages/en-US.json | 1 +
server/lib/billing/tierMatrix.ts | 6 +-
.../routers/billing/featureLifecycle.ts | 13 +-
server/routers/idp/validateOidcCallback.ts | 15 +-
.../users/[userId]/access-controls/page.tsx | 5 +-
src/components/RoleMappingConfigFields.tsx | 193 ++++++++++++++----
6 files changed, 178 insertions(+), 55 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index 505378b7f..673ce4949 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1956,6 +1956,7 @@
"roleMappingAssignRoles": "Assign Roles",
"roleMappingAddMappingRule": "Add Mapping Rule",
"roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.",
+ "roleMappingRawExpressionResultDescriptionSingleRole": "Expression must evaluate to a string (a single role name).",
"roleMappingMatchValuePlaceholder": "Match value (for example: admin)",
"roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)",
"roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.",
diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts
index c08bcea71..a66f566a9 100644
--- a/server/lib/billing/tierMatrix.ts
+++ b/server/lib/billing/tierMatrix.ts
@@ -15,7 +15,8 @@ export enum TierFeature {
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
- SshPam = "sshPam"
+ SshPam = "sshPam",
+ FullRbac = "fullRbac"
}
export const tierMatrix: Record = {
@@ -48,5 +49,6 @@ export const tierMatrix: Record = {
"enterprise"
],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
- [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"]
+ [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
+ [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"]
};
diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts
index 9536a87f0..330cf6e03 100644
--- a/server/private/routers/billing/featureLifecycle.ts
+++ b/server/private/routers/billing/featureLifecycle.ts
@@ -26,9 +26,10 @@ import {
orgs,
resources,
roles,
- siteResources
+ siteResources,
+ userOrgRoles
} from "@server/db";
-import { eq } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
/**
* Get the maximum allowed retention days for a given tier
@@ -291,6 +292,10 @@ async function disableFeature(
await disableSshPam(orgId);
break;
+ case TierFeature.FullRbac:
+ await disableFullRbac(orgId);
+ break;
+
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
@@ -326,6 +331,10 @@ async function disableSshPam(orgId: string): Promise {
);
}
+async function disableFullRbac(orgId: string): Promise {
+ logger.info(`Disabled full RBAC for org ${orgId}`);
+}
+
async function disableLoginPageBranding(orgId: string): Promise {
const [existingBranding] = await db
.select()
diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts
index 7f39aa38d..4de52f530 100644
--- a/server/routers/idp/validateOidcCallback.ts
+++ b/server/routers/idp/validateOidcCallback.ts
@@ -36,6 +36,7 @@ import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
+import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
assignUserToOrg,
@@ -415,7 +416,15 @@ export async function validateOidcCallback(
roleMappingResult
);
- if (!roleNames.length) {
+ const supportsMultiRole = await isLicensedOrSubscribed(
+ org.orgId,
+ tierMatrix.fullRbac
+ );
+ const effectiveRoleNames = supportsMultiRole
+ ? roleNames
+ : roleNames.slice(0, 1);
+
+ if (!effectiveRoleNames.length) {
logger.error("Role mapping returned no valid roles", {
roleMappingResult
});
@@ -428,14 +437,14 @@ export async function validateOidcCallback(
.where(
and(
eq(roles.orgId, org.orgId),
- inArray(roles.name, roleNames)
+ inArray(roles.name, effectiveRoleNames)
)
);
if (!roleRes.length) {
logger.error("No mapped roles found in organization", {
orgId: org.orgId,
- roleNames
+ roleNames: effectiveRoleNames
});
continue;
}
diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
index c9ed7d561..eb280f2f3 100644
--- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
+++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
@@ -69,10 +69,7 @@ export default function AccessControlsPage() {
const t = useTranslations();
const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } =
usePaidStatus();
- const multiRoleFeatureTiers = Array.from(
- new Set([...tierMatrix.sshPam, ...tierMatrix.orgOidc])
- );
- const isPaid = isPaidUser(multiRoleFeatureTiers);
+ const isPaid = isPaidUser(tierMatrix.fullRbac);
const supportsMultipleRolesPerUser = isPaid;
const showMultiRolePaywallMessage =
!env.flags.disableEnterpriseFeatures &&
diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx
index deb2cc9ac..12790d4aa 100644
--- a/src/components/RoleMappingConfigFields.tsx
+++ b/src/components/RoleMappingConfigFields.tsx
@@ -5,13 +5,17 @@ import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import {
createMappingBuilderRule,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import { build } from "@server/build";
export type RoleMappingRoleOption = {
roleId: number;
@@ -52,10 +56,17 @@ export default function RoleMappingConfigFields({
showFreeformRoleNamesHint = false
}: RoleMappingConfigFieldsProps) {
const t = useTranslations();
+ const { env } = useEnvContext();
+ const { isPaidUser } = usePaidStatus();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null
>(null);
+ const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
+ const showSingleRoleDisclaimer =
+ !env.flags.disableEnterpriseFeatures &&
+ !isPaidUser(tierMatrix.fullRbac);
+
const restrictToOrgRoles = roles.length > 0;
const roleOptions = useMemo(
@@ -67,13 +78,40 @@ export default function RoleMappingConfigFields({
[roles]
);
+ useEffect(() => {
+ if (
+ !supportsMultipleRolesPerUser &&
+ mappingBuilderRules.length > 1
+ ) {
+ onMappingBuilderRulesChange([mappingBuilderRules[0]]);
+ }
+ }, [
+ supportsMultipleRolesPerUser,
+ mappingBuilderRules,
+ onMappingBuilderRulesChange
+ ]);
+
+ useEffect(() => {
+ if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) {
+ onFixedRoleNamesChange([fixedRoleNames[0]]);
+ }
+ }, [
+ supportsMultipleRolesPerUser,
+ fixedRoleNames,
+ onFixedRoleNamesChange
+ ]);
+
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`;
+ const mappingBuilderShowsRemoveColumn =
+ supportsMultipleRolesPerUser || mappingBuilderRules.length > 1;
+
/** Same template on header + rows so 1fr/1.75fr columns line up (auto third col differs per row otherwise). */
- const mappingRulesGridClass =
- "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3";
+ const mappingRulesGridClass = mappingBuilderShowsRemoveColumn
+ ? "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3"
+ : "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)] md:gap-x-3";
return (
@@ -119,6 +157,13 @@ export default function RoleMappingConfigFields({
+ {showSingleRoleDisclaimer && (
+
+ {build === "saas"
+ ? t("singleRolePerUserPlanNotice")
+ : t("singleRolePerUserEditionNotice")}
+
+ )}
{roleMappingMode === "fixedRoles" && (
@@ -129,19 +174,37 @@ export default function RoleMappingConfigFields({
text: name
}))}
setTags={(nextTags) => {
+ const prevTags = fixedRoleNames.map((name) => ({
+ id: name,
+ text: name
+ }));
const next =
typeof nextTags === "function"
- ? nextTags(
- fixedRoleNames.map((name) => ({
- id: name,
- text: name
- }))
- )
+ ? nextTags(prevTags)
: nextTags;
- onFixedRoleNamesChange([
+ let names = [
...new Set(next.map((tag) => tag.text))
- ]);
+ ];
+
+ if (!supportsMultipleRolesPerUser) {
+ if (
+ names.length === 0 &&
+ fixedRoleNames.length > 0
+ ) {
+ onFixedRoleNamesChange([
+ fixedRoleNames[
+ fixedRoleNames.length - 1
+ ]!
+ ]);
+ return;
+ }
+ if (names.length > 1) {
+ names = [names[names.length - 1]!];
+ }
+ }
+
+ onFixedRoleNamesChange(names);
}}
activeTagIndex={activeFixedRoleTagIndex}
setActiveTagIndex={setActiveFixedRoleTagIndex}
@@ -191,7 +254,9 @@ export default function RoleMappingConfigFields({
{t("roleMappingAssignRoles")}
-
+ {mappingBuilderShowsRemoveColumn ? (
+
+ ) : null}
{mappingBuilderRules.map((rule, index) => (
@@ -204,6 +269,10 @@ export default function RoleMappingConfigFields({
showFreeformRoleNamesHint={
showFreeformRoleNamesHint
}
+ supportsMultipleRolesPerUser={
+ supportsMultipleRolesPerUser
+ }
+ showRemoveButton={mappingBuilderShowsRemoveColumn}
rule={rule}
onChange={(nextRule) => {
const nextRules = mappingBuilderRules.map(
@@ -227,18 +296,20 @@ export default function RoleMappingConfigFields({
))}
-
+ {supportsMultipleRolesPerUser ? (
+
+ ) : null}
)}
@@ -250,7 +321,11 @@ export default function RoleMappingConfigFields({
placeholder={t("roleMappingExpressionPlaceholder")}
/>
- {t("roleMappingRawExpressionResultDescription")}
+ {supportsMultipleRolesPerUser
+ ? t("roleMappingRawExpressionResultDescription")
+ : t(
+ "roleMappingRawExpressionResultDescriptionSingleRole"
+ )}
)}
@@ -265,6 +340,8 @@ function BuilderRuleRow({
showFreeformRoleNamesHint,
fieldIdPrefix,
mappingRulesGridClass,
+ supportsMultipleRolesPerUser,
+ showRemoveButton,
onChange,
onRemove
}: {
@@ -274,6 +351,8 @@ function BuilderRuleRow({
showFreeformRoleNamesHint: boolean;
fieldIdPrefix: string;
mappingRulesGridClass: string;
+ supportsMultipleRolesPerUser: boolean;
+ showRemoveButton: boolean;
onChange: (rule: MappingBuilderRule) => void;
onRemove: () => void;
}) {
@@ -311,20 +390,44 @@ function BuilderRuleRow({
text: name
}))}
setTags={(nextTags) => {
+ const prevRoleTags = rule.roleNames.map(
+ (name) => ({
+ id: name,
+ text: name
+ })
+ );
const next =
typeof nextTags === "function"
- ? nextTags(
- rule.roleNames.map((name) => ({
- id: name,
- text: name
- }))
- )
+ ? nextTags(prevRoleTags)
: nextTags;
+
+ let names = [
+ ...new Set(next.map((tag) => tag.text))
+ ];
+
+ if (!supportsMultipleRolesPerUser) {
+ if (
+ names.length === 0 &&
+ rule.roleNames.length > 0
+ ) {
+ onChange({
+ ...rule,
+ roleNames: [
+ rule.roleNames[
+ rule.roleNames.length - 1
+ ]!
+ ]
+ });
+ return;
+ }
+ if (names.length > 1) {
+ names = [names[names.length - 1]!];
+ }
+ }
+
onChange({
...rule,
- roleNames: [
- ...new Set(next.map((tag) => tag.text))
- ]
+ roleNames: names
});
}}
activeTagIndex={activeTagIndex}
@@ -351,16 +454,18 @@ function BuilderRuleRow({
)}
-
-
-
+ {showRemoveButton ? (
+
+
+
+ ) : null}
);
}
From ba529ad14ed582b1e23f01656e70168b9b54b7e1 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Sat, 28 Mar 2026 18:20:56 -0700
Subject: [PATCH 11/19] hide google and azure idp properly
---
.../settings/(private)/idp/create/page.tsx | 2 -
src/app/admin/idp/create/page.tsx | 135 ++++++++++--------
.../idp/OidcIdpProviderTypeSelect.tsx | 96 +++++++------
3 files changed, 128 insertions(+), 105 deletions(-)
diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx
index 17adcb6b6..c0fc30a8f 100644
--- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx
+++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx
@@ -219,7 +219,6 @@ export default function Page() {
}
const disabled = !isPaidUser(tierMatrix.orgOidc);
- const templatesPaid = isPaidUser(tierMatrix.orgOidc);
return (
<>
@@ -254,7 +253,6 @@ export default function Page() {
{
applyOidcIdpProviderType(form.setValue, next);
}}
diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx
index 40d4a3b32..5039d255c 100644
--- a/src/app/admin/idp/create/page.tsx
+++ b/src/app/admin/idp/create/page.tsx
@@ -36,7 +36,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -93,17 +93,18 @@ export default function Page() {
});
const watchedType = form.watch("type");
-
- useEffect(() => {
- if (
- !templatesPaid &&
- (watchedType === "google" || watchedType === "azure")
- ) {
- applyOidcIdpProviderType(form.setValue, "oidc");
- }
- }, [templatesPaid, watchedType, form.setValue]);
+ const templatesLocked =
+ !templatesPaid &&
+ (watchedType === "google" || watchedType === "azure");
async function onSubmit(data: CreateIdpFormValues) {
+ if (
+ !templatesPaid &&
+ (data.type === "google" || data.type === "azure")
+ ) {
+ return;
+ }
+
setCreateLoading(true);
try {
@@ -166,10 +167,6 @@ export default function Page() {
- {!templatesPaid ? (
-
- ) : null}
-
@@ -181,64 +178,79 @@ export default function Page() {
+ {templatesLocked ? (
+
+ ) : null}
{
applyOidcIdpProviderType(form.setValue, next);
}}
/>
-
-
-
-
+
+
+ {
+ form.setValue(
+ "autoProvision",
+ checked
+ );
+ }}
+ />
+
+
+ {t(
+ "idpAutoProvisionUsersDescription"
+ )}
+
+
+
+
+
+
@@ -638,7 +651,7 @@ export default function Page() {
-
- {t("idpAutoProvisionUsersDescription")}
-
From 2828dee94c9fa6ed4f2df1867bc97a207558f037 Mon Sep 17 00:00:00 2001
From: miloschwartz
Date: Sun, 29 Mar 2026 12:11:22 -0700
Subject: [PATCH 19/19] support multi role on create user and invites
---
server/db/pg/schema/schema.ts | 20 +-
server/db/sqlite/schema/schema.ts | 20 +-
server/lib/userOrg.ts | 19 +-
server/routers/idp/validateOidcCallback.ts | 14 +-
server/routers/user/acceptInvite.ts | 43 ++-
server/routers/user/createOrgUser.ts | 94 ++++--
server/routers/user/inviteUser.ts | 83 +++--
server/routers/user/listInvitations.ts | 64 +++-
.../settings/access/invitations/page.tsx | 12 +-
.../users/[userId]/access-controls/page.tsx | 107 ++-----
.../settings/access/users/create/page.tsx | 288 ++++++++----------
src/components/InvitationsTable.tsx | 14 +-
src/components/OrgRolesTagField.tsx | 117 +++++++
src/components/RegenerateInvitationForm.tsx | 18 +-
src/components/UserRoleBadges.tsx | 69 +++++
src/components/UsersTable.tsx | 63 +---
16 files changed, 629 insertions(+), 416 deletions(-)
create mode 100644 src/components/OrgRolesTagField.tsx
create mode 100644 src/components/UserRoleBadges.tsx
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 0346495e3..2bd9624e7 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -6,6 +6,7 @@ import {
index,
integer,
pgTable,
+ primaryKey,
real,
serial,
text,
@@ -467,12 +468,22 @@ export const userInvites = pgTable("userInvites", {
.references(() => orgs.orgId, { onDelete: "cascade" }),
email: varchar("email").notNull(),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
- tokenHash: varchar("token").notNull(),
- roleId: integer("roleId")
- .notNull()
- .references(() => roles.roleId, { onDelete: "cascade" })
+ tokenHash: varchar("token").notNull()
});
+export const userInviteRoles = pgTable(
+ "userInviteRoles",
+ {
+ inviteId: varchar("inviteId")
+ .notNull()
+ .references(() => userInvites.inviteId, { onDelete: "cascade" }),
+ roleId: integer("roleId")
+ .notNull()
+ .references(() => roles.roleId, { onDelete: "cascade" })
+ },
+ (t) => [primaryKey({ columns: [t.inviteId, t.roleId] })]
+);
+
export const resourcePincode = pgTable("resourcePincode", {
pincodeId: serial("pincodeId").primaryKey(),
resourceId: integer("resourceId")
@@ -1048,6 +1059,7 @@ export type UserSite = InferSelectModel;
export type RoleResource = InferSelectModel;
export type UserResource = InferSelectModel;
export type UserInvite = InferSelectModel;
+export type UserInviteRole = InferSelectModel;
export type UserOrg = InferSelectModel;
export type UserOrgRole = InferSelectModel;
export type ResourceSession = InferSelectModel;
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index a4a0c6b8e..b43f3b4a6 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -3,6 +3,7 @@ import { InferSelectModel } from "drizzle-orm";
import {
index,
integer,
+ primaryKey,
sqliteTable,
text,
unique
@@ -804,12 +805,22 @@ export const userInvites = sqliteTable("userInvites", {
.references(() => orgs.orgId, { onDelete: "cascade" }),
email: text("email").notNull(),
expiresAt: integer("expiresAt").notNull(),
- tokenHash: text("token").notNull(),
- roleId: integer("roleId")
- .notNull()
- .references(() => roles.roleId, { onDelete: "cascade" })
+ tokenHash: text("token").notNull()
});
+export const userInviteRoles = sqliteTable(
+ "userInviteRoles",
+ {
+ inviteId: text("inviteId")
+ .notNull()
+ .references(() => userInvites.inviteId, { onDelete: "cascade" }),
+ roleId: integer("roleId")
+ .notNull()
+ .references(() => roles.roleId, { onDelete: "cascade" })
+ },
+ (t) => [primaryKey({ columns: [t.inviteId, t.roleId] })]
+);
+
export const resourcePincode = sqliteTable("resourcePincode", {
pincodeId: integer("pincodeId").primaryKey({
autoIncrement: true
@@ -1152,6 +1163,7 @@ export type UserSite = InferSelectModel;
export type RoleResource = InferSelectModel;
export type UserResource = InferSelectModel;
export type UserInvite = InferSelectModel;
+export type UserInviteRole = InferSelectModel;
export type UserOrg = InferSelectModel;
export type UserOrgRole = InferSelectModel;
export type ResourceSession = InferSelectModel;
diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts
index fb0b88c2b..809266b73 100644
--- a/server/lib/userOrg.ts
+++ b/server/lib/userOrg.ts
@@ -19,15 +19,22 @@ import { FeatureId } from "@server/lib/billing";
export async function assignUserToOrg(
org: Org,
values: typeof userOrgs.$inferInsert,
- roleId: number,
+ roleIds: number[],
trx: Transaction | typeof db = db
) {
+ const uniqueRoleIds = [...new Set(roleIds)];
+ if (uniqueRoleIds.length === 0) {
+ throw new Error("assignUserToOrg requires at least one roleId");
+ }
+
const [userOrg] = await trx.insert(userOrgs).values(values).returning();
- await trx.insert(userOrgRoles).values({
- userId: userOrg.userId,
- orgId: userOrg.orgId,
- roleId
- });
+ await trx.insert(userOrgRoles).values(
+ uniqueRoleIds.map((roleId) => ({
+ userId: userOrg.userId,
+ orgId: userOrg.orgId,
+ roleId
+ }))
+ );
// calculate if the user is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts
index 4de52f530..7c9e53cf2 100644
--- a/server/routers/idp/validateOidcCallback.ts
+++ b/server/routers/idp/validateOidcCallback.ts
@@ -623,9 +623,7 @@ export async function validateOidcCallback(
if (orgsToAdd.length > 0) {
for (const org of orgsToAdd) {
- const [initialRoleId, ...additionalRoleIds] =
- org.roleIds;
- if (!initialRoleId) {
+ if (org.roleIds.length === 0) {
continue;
}
@@ -641,17 +639,9 @@ export async function validateOidcCallback(
userId: userId!,
autoProvisioned: true,
},
- initialRoleId,
+ org.roleIds,
trx
);
-
- for (const roleId of additionalRoleIds) {
- await trx.insert(userOrgRoles).values({
- userId: userId!,
- orgId: org.orgId,
- roleId
- });
- }
}
}
}
diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts
index 30d3be7b9..88010e580 100644
--- a/server/routers/user/acceptInvite.ts
+++ b/server/routers/user/acceptInvite.ts
@@ -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,
@@ -167,7 +184,7 @@ export async function acceptInvite(
userId: existingUser[0].userId,
orgId: existingInvite.orgId
},
- existingInvite.roleId,
+ inviteRoleIds,
trx
);
diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts
index 237b7111e..ddc37d3a2 100644
--- a/server/routers/user/createOrgUser.ts
+++ b/server/routers/user/createOrgUser.ts
@@ -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)
@@ -228,7 +272,7 @@ export async function createOrgUser(
userId: existingUser.userId,
autoProvisioned: false,
},
- role.roleId,
+ uniqueRoleIds,
trx
);
} else {
@@ -255,7 +299,7 @@ export async function createOrgUser(
userId: newUser.userId,
autoProvisioned: false,
},
- role.roleId,
+ uniqueRoleIds,
trx
);
}
diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts
index b0632da9e..7ac1849b9 100644
--- a/server/routers/user/inviteUser.ts
+++ b/server/routers/user/inviteUser.ts
@@ -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;
+export type InviteUserBody = z.input;
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(email)) || 0;
+ const attempts =
+ (await cache.get("regenerateInvite:" + email)) || 0;
if (attempts >= 3) {
return next(
createHttpError(
@@ -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)}`;
diff --git a/server/routers/user/listInvitations.ts b/server/routers/user/listInvitations.ts
index 2733c8395..1f4bcc02c 100644
--- a/server/routers/user/listInvitations.ts
+++ b/server/routers/user/listInvitations.ts
@@ -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 {
+ 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>>;
+ invitations: InvitationListRow[];
pagination: { total: number; limit: number; offset: number };
};
@@ -95,7 +137,7 @@ export async function listInvitations(
const [{ count }] = await db
.select({ count: sql`count(*)` })
.from(userInvites)
- .where(sql`${userInvites.orgId} = ${orgId}`);
+ .where(eq(userInvites.orgId, orgId));
return response(res, {
data: {
diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx
index b6ee14484..00cb0ffc8 100644
--- a/src/app/[orgId]/settings/access/invitations/page.tsx
+++ b/src/app/[orgId]/settings/access/invitations/page.tsx
@@ -29,9 +29,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
let invitations: {
inviteId: string;
email: string;
- expiresAt: string;
- roleId: number;
- roleName?: string;
+ expiresAt: number;
+ roles: { roleId: number; roleName: string | null }[];
}[] = [];
let hasInvitations = false;
@@ -66,12 +65,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
}
const invitationRows: InvitationRow[] = invitations.map((invite) => {
+ const names = invite.roles
+ .map((r) => r.roleName || t("accessRoleUnknown"))
+ .filter(Boolean);
return {
id: invite.inviteId,
email: invite.email,
expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
- role: invite.roleName || t("accessRoleUnknown"),
- roleId: invite.roleId
+ roleLabels: names,
+ roleIds: invite.roles.map((r) => r.roleId)
};
});
diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
index 16ae0b3a5..9ab9e93fa 100644
--- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
+++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx
@@ -3,18 +3,17 @@
import {
Form,
FormControl,
- FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Checkbox } from "@app/components/ui/checkbox";
-import { Tag, TagInput } from "@app/components/tags/tag-input";
+import OrgRolesTagField from "@app/components/OrgRolesTagField";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role";
@@ -67,8 +66,7 @@ export default function AccessControlsPage() {
);
const t = useTranslations();
- const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } =
- usePaidStatus();
+ const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac);
const supportsMultipleRolesPerUser = isPaid;
const showMultiRolePaywallMessage =
@@ -131,40 +129,10 @@ export default function AccessControlsPage() {
text: role.name
}));
- function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
- const prev = form.getValues("roles");
- const nextValue =
- typeof updater === "function" ? updater(prev) : updater;
- const next = supportsMultipleRolesPerUser
- ? nextValue
- : nextValue.length > 1
- ? [nextValue[nextValue.length - 1]]
- : nextValue;
-
- // In single-role mode, selecting the currently selected role can transiently
- // emit an empty tag list from TagInput; keep the prior selection.
- if (
- !supportsMultipleRolesPerUser &&
- next.length === 0 &&
- prev.length > 0
- ) {
- form.setValue("roles", [prev[prev.length - 1]], {
- shouldDirty: true
- });
- return;
- }
-
- if (next.length === 0) {
- toast({
- variant: "destructive",
- title: t("accessRoleErrorAdd"),
- description: t("accessRoleSelectPlease")
- });
- return;
- }
-
- form.setValue("roles", next, { shouldDirty: true });
- }
+ const paywallMessage =
+ build === "saas"
+ ? t("singleRolePerUserPlanNotice")
+ : t("singleRolePerUserEditionNotice");
async function onSubmit(values: z.infer) {
if (values.roles.length === 0) {
@@ -255,53 +223,22 @@ export default function AccessControlsPage() {
)}
- (
-
- {t("roles")}
-
-
-
- {showMultiRolePaywallMessage && (
-
- {build === "saas"
- ? t(
- "singleRolePerUserPlanNotice"
- )
- : t(
- "singleRolePerUserEditionNotice"
- )}
-
- )}
-
-
- )}
+ label={t("roles")}
+ placeholder={t("accessRoleSelect2")}
+ allRoleOptions={allRoleOptions}
+ supportsMultipleRolesPerUser={
+ supportsMultipleRolesPerUser
+ }
+ showMultiRolePaywallMessage={
+ showMultiRolePaywallMessage
+ }
+ paywallMessage={paywallMessage}
+ loading={loading}
+ activeTagIndex={activeRoleTagIndex}
+ setActiveTagIndex={setActiveRoleTagIndex}
/>
{user.idpAutoProvision && (
diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx
index 08737f5e2..0263d2b72 100644
--- a/src/app/[orgId]/settings/access/users/create/page.tsx
+++ b/src/app/[orgId]/settings/access/users/create/page.tsx
@@ -32,7 +32,7 @@ import {
} from "@app/components/ui/select";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
-import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
+import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -49,6 +49,7 @@ import { build } from "@server/build";
import Image from "next/image";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import OrgRolesTagField from "@app/components/OrgRolesTagField";
type UserType = "internal" | "oidc";
@@ -76,7 +77,14 @@ export default function Page() {
const api = createApiClient({ env });
const t = useTranslations();
- const { hasSaasSubscription } = usePaidStatus();
+ const { hasSaasSubscription, isPaidUser } = usePaidStatus();
+ const isPaid = isPaidUser(tierMatrix.fullRbac);
+ const supportsMultipleRolesPerUser = isPaid;
+ const showMultiRolePaywallMessage =
+ !env.flags.disableEnterpriseFeatures &&
+ ((build === "saas" && !isPaid) ||
+ (build === "enterprise" && !isPaid) ||
+ (build === "oss" && !isPaid));
const [selectedOption, setSelectedOption] = useState(
"internal"
@@ -89,19 +97,34 @@ export default function Page() {
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [userOptions, setUserOptions] = useState([]);
const [dataLoaded, setDataLoaded] = useState(false);
+ const [activeInviteRoleTagIndex, setActiveInviteRoleTagIndex] = useState<
+ number | null
+ >(null);
+ const [activeOidcRoleTagIndex, setActiveOidcRoleTagIndex] = useState<
+ number | null
+ >(null);
+
+ const roleTagsFieldSchema = z
+ .array(
+ z.object({
+ id: z.string(),
+ text: z.string()
+ })
+ )
+ .min(1, { message: t("accessRoleSelectPlease") });
const internalFormSchema = z.object({
email: z.email({ message: t("emailInvalid") }),
validForHours: z
.string()
.min(1, { message: t("inviteValidityDuration") }),
- roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
+ roles: roleTagsFieldSchema
});
const googleAzureFormSchema = z.object({
email: z.email({ message: t("emailInvalid") }),
name: z.string().optional(),
- roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
+ roles: roleTagsFieldSchema
});
const genericOidcFormSchema = z.object({
@@ -111,7 +134,7 @@ export default function Page() {
.optional()
.or(z.literal("")),
name: z.string().optional(),
- roleId: z.string().min(1, { message: t("accessRoleSelectPlease") })
+ roles: roleTagsFieldSchema
});
const formatIdpType = (type: string) => {
@@ -166,12 +189,22 @@ export default function Page() {
{ hours: 168, name: t("day", { count: 7 }) }
];
+ const allRoleOptions = roles.map((role) => ({
+ id: role.roleId.toString(),
+ text: role.name
+ }));
+
+ const invitePaywallMessage =
+ build === "saas"
+ ? t("singleRolePerUserPlanNotice")
+ : t("singleRolePerUserEditionNotice");
+
const internalForm = useForm({
resolver: zodResolver(internalFormSchema),
defaultValues: {
email: "",
validForHours: "72",
- roleId: ""
+ roles: [] as { id: string; text: string }[]
}
});
@@ -180,7 +213,7 @@ export default function Page() {
defaultValues: {
email: "",
name: "",
- roleId: ""
+ roles: [] as { id: string; text: string }[]
}
});
@@ -190,7 +223,7 @@ export default function Page() {
username: "",
email: "",
name: "",
- roleId: ""
+ roles: [] as { id: string; text: string }[]
}
});
@@ -305,16 +338,17 @@ export default function Page() {
) {
setLoading(true);
- const res = await api
- .post>(
- `/org/${orgId}/create-invite`,
- {
- email: values.email,
- roleId: parseInt(values.roleId),
- validHours: parseInt(values.validForHours),
- sendEmail: sendEmail
- } as InviteUserBody
- )
+ const roleIds = values.roles.map((r) => parseInt(r.id, 10));
+
+ const res = await api.post>(
+ `/org/${orgId}/create-invite`,
+ {
+ email: values.email,
+ roleIds,
+ validHours: parseInt(values.validForHours),
+ sendEmail
+ }
+ )
.catch((e) => {
if (e.response?.status === 409) {
toast({
@@ -358,6 +392,8 @@ export default function Page() {
setLoading(true);
+ const roleIds = values.roles.map((r) => parseInt(r.id, 10));
+
const res = await api
.put(`/org/${orgId}/user`, {
username: values.email, // Use email as username for Google/Azure
@@ -365,7 +401,7 @@ export default function Page() {
name: values.name,
type: "oidc",
idpId: selectedUserOption.idpId,
- roleId: parseInt(values.roleId)
+ roleIds
})
.catch((e) => {
toast({
@@ -400,6 +436,8 @@ export default function Page() {
setLoading(true);
+ const roleIds = values.roles.map((r) => parseInt(r.id, 10));
+
const res = await api
.put(`/org/${orgId}/user`, {
username: values.username,
@@ -407,7 +445,7 @@ export default function Page() {
name: values.name,
type: "oidc",
idpId: selectedUserOption.idpId,
- roleId: parseInt(values.roleId)
+ roleIds
})
.catch((e) => {
toast({
@@ -575,52 +613,32 @@ export default function Page() {
)}
/>
- (
-
-
- {t("role")}
-
-
-
-
+
{env.email.emailEnabled && (
@@ -764,52 +782,32 @@ export default function Page() {
)}
/>
- (
-
-
- {t("role")}
-
-
-
-
+
@@ -909,52 +907,32 @@ export default function Page() {
)}
/>
- (
-
-
- {t("role")}
-
-
-
-
+
diff --git a/src/components/InvitationsTable.tsx b/src/components/InvitationsTable.tsx
index 0d2d3e9b6..4fec9e5fc 100644
--- a/src/components/InvitationsTable.tsx
+++ b/src/components/InvitationsTable.tsx
@@ -1,6 +1,5 @@
"use client";
-import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
@@ -21,13 +20,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import moment from "moment";
import { useRouter } from "next/navigation";
+import UserRoleBadges from "@app/components/UserRoleBadges";
export type InvitationRow = {
id: string;
email: string;
expiresAt: string;
- role: string;
- roleId: number;
+ roleLabels: string[];
+ roleIds: number[];
};
type InvitationsTableProps = {
@@ -90,9 +90,13 @@ export default function InvitationsTable({
}
},
{
- accessorKey: "role",
+ id: "roles",
+ accessorFn: (row) => row.roleLabels.join(", "),
friendlyName: t("role"),
- header: () => {t("role")}
+ header: () => {t("role")},
+ cell: ({ row }) => (
+
+ )
},
{
id: "dots",
diff --git a/src/components/OrgRolesTagField.tsx b/src/components/OrgRolesTagField.tsx
new file mode 100644
index 000000000..dcd679663
--- /dev/null
+++ b/src/components/OrgRolesTagField.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@app/components/ui/form";
+import { Tag, TagInput } from "@app/components/tags/tag-input";
+import { toast } from "@app/hooks/useToast";
+import { useTranslations } from "next-intl";
+import type { Dispatch, SetStateAction } from "react";
+import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
+
+export type RoleTag = {
+ id: string;
+ text: string;
+};
+
+type OrgRolesTagFieldProps = {
+ form: Pick, "control" | "getValues" | "setValue">;
+ /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
+ name?: Path;
+ label: string;
+ placeholder: string;
+ allRoleOptions: Tag[];
+ supportsMultipleRolesPerUser: boolean;
+ showMultiRolePaywallMessage: boolean;
+ paywallMessage: string;
+ loading?: boolean;
+ activeTagIndex: number | null;
+ setActiveTagIndex: Dispatch>;
+};
+
+export default function OrgRolesTagField({
+ form,
+ name = "roles" as Path,
+ label,
+ placeholder,
+ allRoleOptions,
+ supportsMultipleRolesPerUser,
+ showMultiRolePaywallMessage,
+ paywallMessage,
+ loading = false,
+ activeTagIndex,
+ setActiveTagIndex
+}: OrgRolesTagFieldProps) {
+ const t = useTranslations();
+
+ function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
+ const prev = form.getValues(name) as Tag[];
+ const nextValue =
+ typeof updater === "function" ? updater(prev) : updater;
+ const next = supportsMultipleRolesPerUser
+ ? nextValue
+ : nextValue.length > 1
+ ? [nextValue[nextValue.length - 1]]
+ : nextValue;
+
+ if (
+ !supportsMultipleRolesPerUser &&
+ next.length === 0 &&
+ prev.length > 0
+ ) {
+ form.setValue(name, [prev[prev.length - 1]] as never, {
+ shouldDirty: true
+ });
+ return;
+ }
+
+ if (next.length === 0) {
+ toast({
+ variant: "destructive",
+ title: t("accessRoleErrorAdd"),
+ description: t("accessRoleSelectPlease")
+ });
+ return;
+ }
+
+ form.setValue(name, next as never, { shouldDirty: true });
+ }
+
+ return (
+ (
+
+ {label}
+
+
+
+ {showMultiRolePaywallMessage && (
+ {paywallMessage}
+ )}
+
+
+ )}
+ />
+ );
+}
diff --git a/src/components/RegenerateInvitationForm.tsx b/src/components/RegenerateInvitationForm.tsx
index 5d067e7d8..ce261d02e 100644
--- a/src/components/RegenerateInvitationForm.tsx
+++ b/src/components/RegenerateInvitationForm.tsx
@@ -32,15 +32,15 @@ type RegenerateInvitationFormProps = {
invitation: {
id: string;
email: string;
- roleId: number;
- role: string;
+ roleIds: number[];
+ roleLabels: string[];
} | null;
onRegenerate: (updatedInvitation: {
id: string;
email: string;
expiresAt: string;
- role: string;
- roleId: number;
+ roleLabels: string[];
+ roleIds: number[];
}) => void;
};
@@ -94,7 +94,7 @@ export default function RegenerateInvitationForm({
try {
const res = await api.post(`/org/${org.org.orgId}/create-invite`, {
email: invitation.email,
- roleId: invitation.roleId,
+ roleIds: invitation.roleIds,
validHours,
sendEmail,
regenerate: true
@@ -127,9 +127,11 @@ export default function RegenerateInvitationForm({
onRegenerate({
id: invitation.id,
email: invitation.email,
- expiresAt: res.data.data.expiresAt,
- role: invitation.role,
- roleId: invitation.roleId
+ expiresAt: new Date(
+ res.data.data.expiresAt
+ ).toISOString(),
+ roleLabels: invitation.roleLabels,
+ roleIds: invitation.roleIds
});
}
} catch (error: any) {
diff --git a/src/components/UserRoleBadges.tsx b/src/components/UserRoleBadges.tsx
new file mode 100644
index 000000000..4888aa107
--- /dev/null
+++ b/src/components/UserRoleBadges.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { Badge, badgeVariants } from "@app/components/ui/badge";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger
+} from "@app/components/ui/popover";
+import { cn } from "@app/lib/cn";
+
+const MAX_ROLE_BADGES = 3;
+
+export default function UserRoleBadges({
+ roleLabels
+}: {
+ roleLabels: string[];
+}) {
+ const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
+ const overflow = roleLabels.slice(MAX_ROLE_BADGES);
+
+ return (
+
+ {visible.map((label, i) => (
+
+ {label}
+
+ ))}
+ {overflow.length > 0 && (
+
+ )}
+
+ );
+}
+
+function OverflowRolesPopover({ labels }: { labels: string[] }) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+ setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ >
+ +{labels.length}
+
+
+ setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ >
+
+ {labels.map((label, i) => (
+ - {label}
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx
index be1d6a345..3e2d4e578 100644
--- a/src/components/UsersTable.tsx
+++ b/src/components/UsersTable.tsx
@@ -12,13 +12,6 @@ import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "@app/components/UsersDataTable";
import { useState, useEffect } from "react";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger
-} from "@app/components/ui/popover";
-import { Badge, badgeVariants } from "@app/components/ui/badge";
-import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
@@ -31,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "./IdpTypeBadge";
+import UserRoleBadges from "./UserRoleBadges";
export type UserRow = {
id: string;
@@ -47,61 +41,6 @@ export type UserRow = {
isOwner: boolean;
};
-const MAX_ROLE_BADGES = 3;
-
-function UserRoleBadges({ roleLabels }: { roleLabels: string[] }) {
- const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
- const overflow = roleLabels.slice(MAX_ROLE_BADGES);
-
- return (
-
- {visible.map((label, i) => (
-
- {label}
-
- ))}
- {overflow.length > 0 && (
-
- )}
-
- );
-}
-
-function OverflowRolesPopover({ labels }: { labels: string[] }) {
- const [open, setOpen] = useState(false);
-
- return (
-
-
- setOpen(true)}
- onMouseLeave={() => setOpen(false)}
- >
- +{labels.length}
-
-
- setOpen(true)}
- onMouseLeave={() => setOpen(false)}
- >
-
- {labels.map((label, i) => (
- - {label}
- ))}
-
-
-
- );
-}
-
type UsersTableProps = {
users: UserRow[];
};