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() { - - + -
- - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - -
+
+ + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + +
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() { - - -
- - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - -
+ +
+ + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + +
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: ( - {t("idpGoogleAlt")} - ) - }, - { - id: "azure", - title: t("idpAzureTitle"), - description: t("idpAzureDescription"), - icon: ( - {t("idpAzureAlt")} - ) - } - ]; - 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); + }} + />
@@ -708,16 +614,6 @@ export default function Page() { />
- - - - - {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")}: + + +
+
)} /> - -
- { - form.setValue( - "autoProvision", - checked - ); - }} - /> -
-
- - {t( - "idpAutoProvisionUsersDescription" - )} - - {form.watch("autoProvision") && ( - - {t.rich( - "idpAdminAutoProvisionPoliciesTabHint", - { - policiesTabLink: ( - chunks - ) => ( - - {chunks} - - ) - } - )} - - )} -
- + + + + {t("idpAutoProvisionUsers")} + + + {t("idpAutoProvisionUsersDescription")} + + + +
+ +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> +
+
+ + {t("idpAutoProvisionUsersDescription")} + + {form.watch("autoProvision") && ( + + {t.rich( + "idpAdminAutoProvisionPoliciesTabHint", + { + policiesTabLink: ( + chunks + ) => ( + + {chunks} + + ) + } + )} + + )} +
+
+ +
+
+ + {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("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + +
+
+
+ + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + + +
+ + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + +
+
+
+
+ )}
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")} + + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpGoogleClientIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpGoogleClientSecretDescription" + )} + + + + )} + /> + + +
+
+
+ )} + + {watchedType === "azure" && ( + + + + {t("idpAzureConfigurationTitle")} + + + {t("idpAzureConfigurationDescription")} + + + + +
+ + ( + + + {t("idpTenantIdLabel")} + + + + + + {t( + "idpAzureTenantIdDescription" + )} + + + + )} + /> + + ( + + + {t("idpClientId")} + + + + + + {t( + "idpAzureClientIdDescription2" + )} + + + + )} + /> + + ( + + + {t("idpClientSecret")} + + + + + + {t( + "idpAzureClientSecretDescription2" + )} + + + + )} + /> + + +
+
+
+ )} + + {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: ( + {t("idpGoogleAlt")} + ) + }, + { + id: "azure", + title: t("idpAzureTitle"), + description: t("idpAzureDescription"), + disabled: !templatesPaid, + icon: ( + {t("idpAzureAlt")} + ) + } + ]; + + return ( +
+
+ {t("idpType")} +
+ +
+ ); +} 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); }} /> - -
- - ( - - - {t("name")} - - - - - - {t("idpDisplayName")} - - - - )} - /> - -
- + + + + ( + + + {t("name")} + + + + + + {t("idpDisplayName")} + + + )} - onCheckedChange={(checked) => { - form.setValue( - "autoProvision", - checked - ); - }} /> -
- - {t("idpAutoProvisionUsersDescription")} - - - -
+ +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> +
+ + {t( + "idpAutoProvisionUsersDescription" + )} + + + + +
+
{watchedType === "google" && ( @@ -624,6 +636,7 @@ export default function Page() { )} +
@@ -638,7 +651,7 @@ export default function Page() { - -
+ + + + + + + -
- {autocompleteOptions.length > 0 ? ( -
+ + {t("noResults")} + - - Suggestions - -
- {autocompleteOptions.map((option, index) => { - const isSelected = index === selectedIndex; + {visibleOptions.map((option) => { + const isChosen = tags.some( + (tag) => tag.text === option.text + ); return ( -
toggleTag(option)} + value={`${option.text} ${option.id}`} + onSelect={() => toggleTag(option)} + className={classStyleProps?.commandItem} > -
- {option.text} - {tags.some( - (tag) => - tag.text === option.text - ) && ( - - - + -
+ /> + {option.text} + ); })} -
- ) : ( -
- {t("noResults")} -
- )} -
+
+
+
diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index 269967ccb..e8cfa370a 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo } from "react"; +import React from "react"; import { Input } from "../ui/input"; import { Button } from "../ui/button"; import { type VariantProps } from "class-variance-authority"; @@ -434,14 +434,6 @@ const TagInput = React.forwardRef( // const filteredAutocompleteOptions = autocompleteFilter // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) // : autocompleteOptions; - const filteredAutocompleteOptions = useMemo(() => { - return (autocompleteOptions || []).filter((option) => - option.text - .toLowerCase() - .includes(inputValue ? inputValue.toLowerCase() : "") - ); - }, [inputValue, autocompleteOptions]); - const displayedTags = sortTags ? [...tags].sort() : tags; const truncatedTags = truncate @@ -571,9 +563,9 @@ const TagInput = React.forwardRef( tags={tags} setTags={setTags} setInputValue={setInputValue} - autocompleteOptions={ - filteredAutocompleteOptions as Tag[] - } + autocompleteOptions={(autocompleteOptions || + []) as Tag[]} + filterQuery={inputValue} setTagCount={setTagCount} maxTags={maxTags} onTagAdd={onTagAdd} diff --git a/src/components/tags/tag-popover.tsx b/src/components/tags/tag-popover.tsx index 533619a7c..93f5e2c04 100644 --- a/src/components/tags/tag-popover.tsx +++ b/src/components/tags/tag-popover.tsx @@ -1,5 +1,10 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger +} from "../ui/popover"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; import { TagList, TagListProps } from "./tag-list"; import { Button } from "../ui/button"; @@ -33,33 +38,27 @@ export const TagPopover: React.FC = ({ ...tagProps }) => { const triggerContainerRef = useRef(null); - const triggerRef = useRef(null); const popoverContentRef = useRef(null); const inputRef = useRef(null); const [popoverWidth, setPopoverWidth] = useState(0); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [inputFocused, setInputFocused] = useState(false); - const [sideOffset, setSideOffset] = useState(0); const t = useTranslations(); useEffect(() => { const handleResize = () => { - if (triggerContainerRef.current && triggerRef.current) { + if (triggerContainerRef.current) { setPopoverWidth(triggerContainerRef.current.offsetWidth); - setSideOffset( - triggerContainerRef.current.offsetWidth - - triggerRef?.current?.offsetWidth - ); } }; - handleResize(); // Call on mount and layout changes + handleResize(); - window.addEventListener("resize", handleResize); // Adjust on window resize + window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); - }, [triggerContainerRef, triggerRef]); + }, []); // Close the popover when clicking outside of it useEffect(() => { @@ -135,52 +134,54 @@ export const TagPopover: React.FC = ({ onOpenChange={handleOpenChange} modal={usePortal} > -
- {React.cloneElement(children as React.ReactElement, { - onFocus: handleInputFocus, - onBlur: handleInputBlur, - ref: inputRef - })} - - - -
+ + + + + +
+ From 00ef6d617f3ff321482941e4146ce72ac6a71e17 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 28 Mar 2026 17:12:21 -0700 Subject: [PATCH 14/19] Handle the roles better in the verify session --- server/db/queries/verifySessionQueries.ts | 23 ++- server/lib/userOrgRoles.ts | 16 +- server/private/routers/hybrid.ts | 226 +++++++++++++++++++++- server/routers/badger/verifySession.ts | 39 ++-- 4 files changed, 266 insertions(+), 38 deletions(-) diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 66f968b02..989e111a7 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,12 @@ -import { db, loginPage, LoginPage, loginPageOrg, Org, orgs, roles } from "@server/db"; +import { + db, + loginPage, + LoginPage, + loginPageOrg, + Org, + orgs, + roles +} from "@server/db"; import { Resource, ResourcePassword, @@ -12,14 +20,12 @@ import { resources, roleResources, sessions, - userOrgRoles, - userOrgs, userResources, users, ResourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; @@ -121,7 +127,7 @@ export async function getRoleName(roleId: number): Promise { */ export async function getRoleResourceAccess( resourceId: number, - roleId: number + roleIds: number[] ) { const roleResourceAccess = await db .select() @@ -129,12 +135,11 @@ export async function getRoleResourceAccess( .where( and( eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) + inArray(roleResources.roleId, roleIds) ) - ) - .limit(1); + ); - return roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; + return roleResourceAccess.length > 0 ? roleResourceAccess : null; } /** diff --git a/server/lib/userOrgRoles.ts b/server/lib/userOrgRoles.ts index 5a4d75659..c3db64af3 100644 --- a/server/lib/userOrgRoles.ts +++ b/server/lib/userOrgRoles.ts @@ -1,4 +1,4 @@ -import { db, userOrgRoles } from "@server/db"; +import { db, roles, userOrgRoles } from "@server/db"; import { and, eq } from "drizzle-orm"; /** @@ -20,3 +20,17 @@ export async function getUserOrgRoleIds( ); return rows.map((r) => r.roleId); } + +export async function getUserOrgRoles( + userId: string, + orgId: string +): Promise<{ roleId: number; roleName: string }[]> { + const rows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) + ); + return rows; +} diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 5ca720594..71fdc7e72 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -124,6 +124,23 @@ const getRoleResourceAccessParamsSchema = z.strictObject({ .pipe(z.int().positive("Resource ID must be a positive integer")) }); +const getResourceAccessParamsSchema = z.strictObject({ + resourceId: z + .string() + .transform(Number) + .pipe(z.int().positive("Resource ID must be a positive integer")) +}); + +const getResourceAccessQuerySchema = z.strictObject({ + roleIds: z + .union([z.array(z.string()), z.string()]) + .transform((val) => + (Array.isArray(val) ? val : [val]) + .map(Number) + .filter((n) => !isNaN(n)) + ) +}); + const getUserResourceAccessParamsSchema = z.strictObject({ userId: z.string().min(1, "User ID is required"), resourceId: z @@ -769,7 +786,7 @@ hybridRouter.get( // Get user organization role hybridRouter.get( - "/user/:userId/org/:orgId/role", + "/user/:userId/org/:orgId/roles", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserOrgRoleParamsSchema.safeParse( @@ -805,6 +822,80 @@ hybridRouter.get( ); } + const userOrgRoleRows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(roles.roleId, userOrgRoles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + return response<{ roleId: number, roleName: string }[]>(res, { + data: userOrgRoleRows, + success: true, + error: false, + message: + userOrgRoleRows.length > 0 + ? "User org roles retrieved successfully" + : "User has no roles in this organization", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user org role" + ) + ); + } + } +); + +// DEPRICATED Get user organization role +// used for backward compatibility with old remote nodes +hybridRouter.get( + "/user/:userId/org/:orgId/role", // <- note the missing s + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserOrgRoleParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, orgId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User is not authorized to access this organization" + ) + ); + } + + // get the roles on the user + const userOrgRoleRows = await db .select({ roleId: userOrgRoles.roleId }) .from(userOrgRoles) @@ -817,8 +908,35 @@ hybridRouter.get( const roleIds = userOrgRoleRows.map((r) => r.roleId); - return response(res, { - data: roleIds, + let roleId: number | null = null; + + if (userOrgRoleRows.length === 0) { + // User has no roles in this organization + roleId = null; + } else if (userOrgRoleRows.length === 1) { + // User has exactly one role, return it + roleId = userOrgRoleRows[0].roleId; + } else { + // User has multiple roles + // Check if any of these roles are also assigned to a resource + // If we find a match, prefer that role; otherwise return the first role + // Get all resources that have any of these roles assigned + const roleResourceMatches = await db + .select({ roleId: roleResources.roleId }) + .from(roleResources) + .where(inArray(roleResources.roleId, roleIds)) + .limit(1); + if (roleResourceMatches.length > 0) { + // Return the first role that's also on a resource + roleId = roleResourceMatches[0].roleId; + } else { + // No resource match found, return the first role + roleId = userOrgRoleRows[0].roleId; + } + } + + return response<{ roleId: number | null }>(res, { + data: { roleId }, success: true, error: false, message: @@ -939,7 +1057,9 @@ hybridRouter.get( data: role?.name ?? null, success: true, error: false, - message: role ? "Role name retrieved successfully" : "Role not found", + message: role + ? "Role name retrieved successfully" + : "Role not found", status: HttpCode.OK }); } catch (error) { @@ -1039,6 +1159,101 @@ hybridRouter.get( } ); +// Check if role has access to resource +hybridRouter.get( + "/resource/:resourceId/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getResourceAccessParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const parsedQuery = getResourceAccessQuerySchema.safeParse( + req.query + ); + const roleIds = parsedQuery.success ? parsedQuery.data.roleIds : []; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + resource.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const roleResourceAccess = await db + .select({ + resourceId: roleResources.resourceId, + roleId: roleResources.roleId + }) + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ); + + const result = + roleResourceAccess.length > 0 ? roleResourceAccess : null; + + return response<{ resourceId: number; roleId: number }[] | null>( + res, + { + data: result, + success: true, + error: false, + message: result + ? "Role resource access retrieved successfully" + : "Role resource access not found", + status: HttpCode.OK + } + ); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role resource access" + ) + ); + } + } +); + // Check if user has direct access to resource hybridRouter.get( "/user/:userId/resource/:resourceId/access", @@ -1937,7 +2152,8 @@ hybridRouter.post( // userAgent: data.userAgent, // TODO: add this // headers: data.body.headers, // query: data.body.query, - originalRequestURL: sanitizeString(logEntry.originalRequestURL) ?? "", + originalRequestURL: + sanitizeString(logEntry.originalRequestURL) ?? "", scheme: sanitizeString(logEntry.scheme) ?? "", host: sanitizeString(logEntry.host) ?? "", path: sanitizeString(logEntry.path) ?? "", diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 182b35dcb..7ea281c9b 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -9,7 +9,7 @@ import { getOrgLoginPage, getUserSessionWithUser } from "@server/db/queries/verifySessionQueries"; -import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import { LoginPage, Org, @@ -798,7 +798,8 @@ async function notAllowed( ) { let loginPage: LoginPage | null = null; if (orgId) { - const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature + const subscribed = await isSubscribed( + // this is fine because the org login page is only a saas feature orgId, tierMatrix.loginPageDomain ); @@ -855,7 +856,10 @@ async function headerAuthChallenged( ) { let loginPage: LoginPage | null = null; if (orgId) { - const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature + const subscribed = await isSubscribed( + orgId, + tierMatrix.loginPageDomain + ); // this is fine because the org login page is only a saas feature if (subscribed) { loginPage = await getOrgLoginPage(orgId); } @@ -917,9 +921,9 @@ async function isUserAllowedToAccessResource( return null; } - const userOrgRoleIds = await getUserOrgRoleIds(user.userId, resource.orgId); + const userOrgRoles = await getUserOrgRoles(user.userId, resource.orgId); - if (!userOrgRoleIds.length) { + if (!userOrgRoles.length) { return null; } @@ -935,23 +939,16 @@ async function isUserAllowedToAccessResource( return null; } - 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) { + const roleResourceAccess = await getRoleResourceAccess( + resource.resourceId, + userOrgRoles.map((r) => r.roleId) + ); + if (roleResourceAccess && roleResourceAccess.length > 0) { return { username: user.username, email: user.email, name: user.name, - role: roleNames.join(", ") + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } @@ -961,15 +958,11 @@ 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 + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } From 757bb39622182378fc49a75584306ae69b23f356 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 28 Mar 2026 21:23:59 -0700 Subject: [PATCH 15/19] Support overriding badger for testing --- server/lib/readConfigFile.ts | 1 + server/private/routers/hybrid.ts | 2 ++ server/routers/badger/verifySession.ts | 2 -- server/routers/traefik/traefikConfigProvider.ts | 17 ++++++++++------- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index f6c8592e9..c3e796fc1 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -79,6 +79,7 @@ export const configSchema = z .default(3001) .transform(stoi) .pipe(portSchema), + badger_override: z.string().optional(), next_port: portSchema .optional() .default(3002) diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 71fdc7e72..13a6f70e0 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -833,6 +833,8 @@ hybridRouter.get( ) ); + logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows); + return response<{ roleId: number, roleName: string }[]>(res, { data: userOrgRoleRows, success: true, diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 7ea281c9b..0b1cc1183 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -3,7 +3,6 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke import { getResourceByDomain, getResourceRules, - getRoleName, getRoleResourceAccess, getUserResourceAccess, getOrgLoginPage, @@ -31,7 +30,6 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; -import { getOrgTierData } from "#dynamic/lib/billing"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index fa76190ff..02f890604 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -30,12 +30,15 @@ export async function traefikConfigProvider( traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server.internal_hostname - }:${config.getRawConfig().server.internal_port}` - ).href, + apiBaseUrl: + config.getRawConfig().server.badger_override || + new URL( + "/api/v1", + `http://${ + config.getRawConfig().server + .internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, userSessionCookieName: config.getRawConfig().server.session_cookie_name, @@ -61,7 +64,7 @@ export async function traefikConfigProvider( return res.status(HttpCode.OK).json(traefikConfig); } catch (e) { - logger.error(`Failed to build Traefik config: ${e}`); + logger.error(e); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: "Failed to build Traefik config" }); From 6f71af278e3e6192aec9e61417d17be9f7fa329a Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 28 Mar 2026 21:29:32 -0700 Subject: [PATCH 16/19] Add basic migration files --- server/setup/migrationsPg.ts | 4 +++- server/setup/migrationsSqlite.ts | 4 +++- server/setup/scriptsPg/1.17.0.ts | 22 ++++++++++++++++++++++ server/setup/scriptsSqlite/1.17.0.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 server/setup/scriptsPg/1.17.0.ts create mode 100644 server/setup/scriptsSqlite/1.17.0.ts diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 1ace73474..9ba0b9767 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -21,6 +21,7 @@ import m12 from "./scriptsPg/1.15.0"; import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; +import m16 from "./scriptsPg/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -41,7 +42,8 @@ const migrations = [ { version: "1.15.0", run: m12 }, { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, - { version: "1.16.0", run: m15 } + { version: "1.16.0", run: m15 }, + { version: "1.17.0", run: m16 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index da7e6b6d1..45a29ec29 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -39,6 +39,7 @@ import m33 from "./scriptsSqlite/1.15.0"; import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; +import m37 from "./scriptsSqlite/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -75,7 +76,8 @@ const migrations = [ { version: "1.15.0", run: m33 }, { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, - { version: "1.16.0", run: m36 } + { version: "1.16.0", run: m36 }, + { version: "1.17.0", run: m37 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.17.0.ts b/server/setup/scriptsPg/1.17.0.ts new file mode 100644 index 000000000..6fb3105d5 --- /dev/null +++ b/server/setup/scriptsPg/1.17.0.ts @@ -0,0 +1,22 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.17.0.ts b/server/setup/scriptsSqlite/1.17.0.ts new file mode 100644 index 000000000..07bd44aec --- /dev/null +++ b/server/setup/scriptsSqlite/1.17.0.ts @@ -0,0 +1,28 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + })(); + + db.pragma("foreign_keys = ON"); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} From 8e821b397fec1b6a57f8a0a434ec6f2831fa3a63 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 28 Mar 2026 21:41:21 -0700 Subject: [PATCH 17/19] Add migration --- server/setup/scriptsPg/1.17.0.ts | 53 ++++++++++++++++++++- server/setup/scriptsSqlite/1.17.0.ts | 70 +++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/server/setup/scriptsPg/1.17.0.ts b/server/setup/scriptsPg/1.17.0.ts index 6fb3105d5..81c42e1a9 100644 --- a/server/setup/scriptsPg/1.17.0.ts +++ b/server/setup/scriptsPg/1.17.0.ts @@ -6,9 +6,37 @@ const version = "1.17.0"; export default async function migration() { console.log(`Running setup script ${version}...`); + // Query existing roleId data from userOrgs before the transaction destroys it + const existingRolesQuery = await db.execute( + sql`SELECT "userId", "orgId", "roleId" FROM "userOrgs" WHERE "roleId" IS NOT NULL` + ); + const existingUserOrgRoles = existingRolesQuery.rows as { + userId: string; + orgId: string; + roleId: number; + }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + try { await db.execute(sql`BEGIN`); + await db.execute(sql` + CREATE TABLE "userOrgRoles" ( + "userId" varchar NOT NULL, + "orgId" varchar NOT NULL, + "roleId" integer NOT NULL, + CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId") + ); + `); + await db.execute(sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgs" DROP COLUMN "roleId";`); + await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { @@ -18,5 +46,28 @@ export default async function migration() { throw e; } + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + try { + for (const row of existingUserOrgRoles) { + await db.execute(sql` + INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId") + VALUES (${row.userId}, ${row.orgId}, ${row.roleId}) + ON CONFLICT DO NOTHING + `); + } + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } catch (e) { + console.error( + "Error while migrating role assignments into userOrgRoles:", + e + ); + throw e; + } + } + console.log(`${version} migration complete`); -} +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.17.0.ts b/server/setup/scriptsSqlite/1.17.0.ts index 07bd44aec..fe7d82de0 100644 --- a/server/setup/scriptsSqlite/1.17.0.ts +++ b/server/setup/scriptsSqlite/1.17.0.ts @@ -13,11 +13,79 @@ export default async function migration() { try { db.pragma("foreign_keys = OFF"); + // Query existing roleId data from userOrgs before the transaction destroys it + const existingUserOrgRoles = db + .prepare( + `SELECT "userId", "orgId", "roleId" FROM 'userOrgs' WHERE "roleId" IS NOT NULL` + ) + .all() as { userId: string; orgId: string; roleId: number }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'userOrgRoles' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'roleId' integer NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `CREATE UNIQUE INDEX 'userOrgRoles_userId_orgId_roleId_unique' ON 'userOrgRoles' ('userId','orgId','roleId');` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_userOrgs' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'isOwner' integer DEFAULT false NOT NULL, + 'autoProvisioned' integer DEFAULT false, + 'pamUsername' text, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';` + ).run(); + db.prepare(`DROP TABLE 'userOrgs';`).run(); + db.prepare( + `ALTER TABLE '__new_userOrgs' RENAME TO 'userOrgs';` + ).run(); })(); db.pragma("foreign_keys = ON"); + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + const insertUserOrgRole = db.prepare( + `INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const row of existingUserOrgRoles) { + insertUserOrgRole.run(row.userId, row.orgId, row.roleId); + } + }); + + insertAll(); + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } + console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); @@ -25,4 +93,4 @@ export default async function migration() { } console.log(`${version} migration complete`); -} +} \ No newline at end of file From bff2ba7cc2ad7706582abeb68eb90c74d53e5460 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 29 Mar 2026 11:34:07 -0700 Subject: [PATCH 18/19] add learn more link --- .../(private)/idp/[idpId]/general/page.tsx | 3 +- .../settings/(private)/idp/create/page.tsx | 3 +- src/app/admin/idp/[idpId]/general/page.tsx | 6 +- src/app/admin/idp/create/page.tsx | 749 +++++++++--------- src/components/AutoProvisionConfigWidget.tsx | 9 +- .../IdpAutoProvisionUsersDescription.tsx | 29 + src/components/IdpCreateWizard.tsx | 4 +- 7 files changed, 420 insertions(+), 383 deletions(-) create mode 100644 src/components/IdpAutoProvisionUsersDescription.tsx 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 37cf400a5..37334e342 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -45,6 +45,7 @@ import { useTranslations } from "next-intl"; import { AxiosResponse } from "axios"; import { ListRolesResponse } from "@server/routers/role"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { @@ -493,7 +494,7 @@ export default function GeneralPage() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index c0fc30a8f..10d86b976 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -1,6 +1,7 @@ "use client"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SettingsContainer, @@ -296,7 +297,7 @@ export default function Page() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index d02925976..c9506b027 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -31,6 +31,7 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useState, useEffect } from "react"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { SwitchInput } from "@app/components/SwitchInput"; import { InfoSection, @@ -349,7 +350,7 @@ export default function GeneralPage() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + @@ -375,9 +376,6 @@ export default function GeneralPage() { />
- - {t("idpAutoProvisionUsersDescription")} - {form.watch("autoProvision") && ( {t.rich( diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 5039d255c..82036c510 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -22,6 +22,7 @@ import { FormMessage } from "@app/components/ui/form"; import HeaderTitle from "@app/components/SettingsSectionTitle"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { SwitchInput } from "@app/components/SwitchInput"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; @@ -94,8 +95,7 @@ export default function Page() { const watchedType = form.watch("type"); const templatesLocked = - !templatesPaid && - (watchedType === "google" || watchedType === "azure"); + !templatesPaid && (watchedType === "google" || watchedType === "azure"); async function onSubmit(data: CreateIdpFormValues) { if ( @@ -223,7 +223,9 @@ export default function Page() {
- - {t( - "idpAutoProvisionUsersDescription" - )} - @@ -251,391 +248,409 @@ export default function Page() { disabled={templatesLocked} className="min-w-0 border-0 p-0 m-0 disabled:pointer-events-none disabled:opacity-60" > - {watchedType === "google" && ( - - - - {t("idpGoogleConfigurationTitle")} - - - {t("idpGoogleConfigurationDescription")} - - - - -
- - ( - - - {t("idpClientId")} - - - - - - {t( - "idpGoogleClientIdDescription" - )} - - - - )} - /> - - ( - - - {t("idpClientSecret")} - - - - - - {t( - "idpGoogleClientSecretDescription" - )} - - - - )} - /> - - -
-
-
- )} - - {watchedType === "azure" && ( - - - - {t("idpAzureConfigurationTitle")} - - - {t("idpAzureConfigurationDescription")} - - - - -
- - ( - - - {t("idpTenantIdLabel")} - - - - - - {t( - "idpAzureTenantIdDescription" - )} - - - - )} - /> - - ( - - - {t("idpClientId")} - - - - - - {t( - "idpAzureClientIdDescription2" - )} - - - - )} - /> - - ( - - - {t("idpClientSecret")} - - - - - - {t( - "idpAzureClientSecretDescription2" - )} - - - - )} - /> - - -
-
-
- )} - - {watchedType === "oidc" && ( - + {watchedType === "google" && ( - {t("idpOidcConfigure")} + {t("idpGoogleConfigurationTitle")} - {t("idpOidcConfigureDescription")} + {t("idpGoogleConfigurationDescription")} -
- - ( - - - {t("idpClientId")} - - - - - - {t( - "idpClientIdDescription" - )} - - - + + + + > + ( + + + {t("idpClientId")} + + + + + + {t( + "idpGoogleClientIdDescription" + )} + + + + )} + /> - ( - - - {t("idpClientSecret")} - - - - - - {t( - "idpClientSecretDescription" - )} - - - - )} - /> - - ( - - - {t("idpAuthUrl")} - - - - - - {t( - "idpAuthUrlDescription" - )} - - - - )} - /> - - ( - - - {t("idpTokenUrl")} - - - - - - {t( - "idpTokenUrlDescription" - )} - - - - )} - /> - - + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpGoogleClientSecretDescription" + )} + + + + )} + /> + + +
+ )} + {watchedType === "azure" && ( - {t("idpToken")} + {t("idpAzureConfigurationTitle")} - {t("idpTokenDescription")} + {t("idpAzureConfigurationDescription")} -
- - ( - - - {t("idpJmespathLabel")} - - - - - - {t( - "idpJmespathLabelDescription" - )} - - - + + + + > + ( + + + {t( + "idpTenantIdLabel" + )} + + + + + + {t( + "idpAzureTenantIdDescription" + )} + + + + )} + /> - ( - - - {t( - "idpJmespathEmailPathOptional" - )} - - - - - - {t( - "idpJmespathEmailPathOptionalDescription" - )} - - - - )} - /> + ( + + + {t("idpClientId")} + + + + + + {t( + "idpAzureClientIdDescription2" + )} + + + + )} + /> - ( - - - {t( - "idpJmespathNamePathOptional" - )} - - - - - - {t( - "idpJmespathNamePathOptionalDescription" - )} - - - - )} - /> - - ( - - - {t( - "idpOidcConfigureScopes" - )} - - - - - - {t( - "idpOidcConfigureScopesDescription" - )} - - - - )} - /> - - + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpAzureClientSecretDescription2" + )} + + + + )} + /> + + +
-
- )} + )} + + {watchedType === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + +
+
+ + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + +
+ + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + +
+
+
+ )} diff --git a/src/components/AutoProvisionConfigWidget.tsx b/src/components/AutoProvisionConfigWidget.tsx index 59849989a..d4df3f50d 100644 --- a/src/components/AutoProvisionConfigWidget.tsx +++ b/src/components/AutoProvisionConfigWidget.tsx @@ -1,14 +1,12 @@ "use client"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { FormDescription } from "@app/components/ui/form"; import { SwitchInput } from "@app/components/SwitchInput"; import { useTranslations } from "next-intl"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { - MappingBuilderRule, - RoleMappingMode -} from "@app/lib/idpRoleMapping"; +import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; type Role = { @@ -60,9 +58,6 @@ export default function AutoProvisionConfigWidget({ onCheckedChange={onAutoProvisionChange} disabled={!isPaidUser(tierMatrix.autoProvisioning)} /> - - {t("idpAutoProvisionUsersDescription")} -
{autoProvision && ( diff --git a/src/components/IdpAutoProvisionUsersDescription.tsx b/src/components/IdpAutoProvisionUsersDescription.tsx new file mode 100644 index 000000000..6839ff245 --- /dev/null +++ b/src/components/IdpAutoProvisionUsersDescription.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +const AUTO_PROVISION_DOCS_URL = + "https://docs.pangolin.net/manage/identity-providers/auto-provisioning"; + +type IdpAutoProvisionUsersDescriptionProps = { + className?: string; +}; + +export default function IdpAutoProvisionUsersDescription({ + className +}: IdpAutoProvisionUsersDescriptionProps) { + const t = useTranslations(); + return ( + + {t("idpAutoProvisionUsersDescription")}{" "} + + {t("learnMore")} + + + ); +} diff --git a/src/components/IdpCreateWizard.tsx b/src/components/IdpCreateWizard.tsx index 3fe7e174f..cddad3287 100644 --- a/src/components/IdpCreateWizard.tsx +++ b/src/components/IdpCreateWizard.tsx @@ -27,6 +27,7 @@ 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 IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { SwitchInput } from "@app/components/SwitchInput"; import { Badge } from "@app/components/ui/badge"; import { useTranslations } from "next-intl"; @@ -163,9 +164,6 @@ export function IdpCreateWizard({ disabled={loading} /> - - {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.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.map((label, i) => ( -
  • {label}
  • - ))} -
-
-
- ); -} - type UsersTableProps = { users: UserRow[]; };