From e13a0769396a6d8cf8293cc96b8a3606c953950f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 26 Mar 2026 16:37:31 -0700 Subject: [PATCH] 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: {