From 2828dee94c9fa6ed4f2df1867bc97a207558f037 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 29 Mar 2026 12:11:22 -0700 Subject: [PATCH] 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[]; };