support multi role on create user and invites

This commit is contained in:
miloschwartz
2026-03-29 12:11:22 -07:00
parent ee6fb34906
commit 2828dee94c
16 changed files with 629 additions and 416 deletions

View File

@@ -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<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>;
export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserInviteRole = InferSelectModel<typeof userInviteRoles>;
export type UserOrg = InferSelectModel<typeof userOrgs>;
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>;

View File

@@ -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<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>;
export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserInviteRole = InferSelectModel<typeof userInviteRoles>;
export type UserOrg = InferSelectModel<typeof userOrgs>;
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>;

View File

@@ -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) {

View File

@@ -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
});
}
}
}
}

View File

@@ -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
);

View File

@@ -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
);
}

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { orgs, roles, userInvites, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm";
import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -18,22 +18,44 @@ import { OpenAPITags, registry } from "@server/openApi";
import { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
import cache from "#dynamic/lib/cache";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const inviteUserParamsSchema = z.strictObject({
orgId: z.string()
});
const inviteUserBodySchema = z.strictObject({
email: z.email().toLowerCase(),
roleId: z.number(),
validHours: z.number().gt(0).lte(168),
sendEmail: z.boolean().optional(),
regenerate: z.boolean().optional()
});
const inviteUserBodySchema = z
.strictObject({
email: z.email().toLowerCase(),
roleIds: z.array(z.number().int().positive()).min(1).optional(),
roleId: z.number().int().positive().optional(),
validHours: z.number().gt(0).lte(168),
sendEmail: z.boolean().optional(),
regenerate: z.boolean().optional()
})
.refine(
(d) =>
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] }
)
.transform((data) => ({
email: data.email,
validHours: data.validHours,
sendEmail: data.sendEmail,
regenerate: data.regenerate,
roleIds: [
...new Set(
data.roleIds && data.roleIds.length > 0
? data.roleIds
: [data.roleId!]
)
]
}));
export type InviteUserBody = z.infer<typeof inviteUserBodySchema>;
export type InviteUserBody = z.input<typeof inviteUserBodySchema>;
export type InviteUserResponse = {
inviteLink: string;
@@ -88,7 +110,7 @@ export async function inviteUser(
const {
email,
validHours,
roleId,
roleIds: uniqueRoleIds,
sendEmail: doEmail,
regenerate
} = parsedBody.data;
@@ -105,14 +127,30 @@ export async function inviteUser(
);
}
// Validate that the roleId belongs to the target organization
const [role] = await db
.select()
.from(roles)
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId)))
.limit(1);
const supportsMultiRole = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.FullRbac]
);
if (!supportsMultiRole && uniqueRoleIds.length > 1) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Multiple roles per user require a subscription or license that includes full RBAC."
)
);
}
if (!role) {
const orgRoles = await db
.select({ roleId: roles.roleId })
.from(roles)
.where(
and(
eq(roles.orgId, orgId),
inArray(roles.roleId, uniqueRoleIds)
)
);
if (orgRoles.length !== uniqueRoleIds.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -191,7 +229,8 @@ export async function inviteUser(
}
if (existingInvite.length) {
const attempts = (await cache.get<number>(email)) || 0;
const attempts =
(await cache.get<number>("regenerateInvite:" + email)) || 0;
if (attempts >= 3) {
return next(
createHttpError(
@@ -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)}`;

View File

@@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userInvites, roles } from "@server/db";
import { userInvites, userInviteRoles, roles } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql } from "drizzle-orm";
import { sql, eq, and, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -29,24 +29,66 @@ const listInvitationsQuerySchema = z.strictObject({
.pipe(z.int().nonnegative())
});
async function queryInvitations(orgId: string, limit: number, offset: number) {
return await db
export type InvitationListRow = {
inviteId: string;
email: string;
expiresAt: number;
roles: { roleId: number; roleName: string | null }[];
};
async function queryInvitations(
orgId: string,
limit: number,
offset: number
): Promise<InvitationListRow[]> {
const inviteRows = await db
.select({
inviteId: userInvites.inviteId,
email: userInvites.email,
expiresAt: userInvites.expiresAt,
roleId: userInvites.roleId,
roleName: roles.name
expiresAt: userInvites.expiresAt
})
.from(userInvites)
.leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`)
.where(sql`${userInvites.orgId} = ${orgId}`)
.where(eq(userInvites.orgId, orgId))
.limit(limit)
.offset(offset);
if (inviteRows.length === 0) {
return [];
}
const inviteIds = inviteRows.map((r) => r.inviteId);
const roleRows = await db
.select({
inviteId: userInviteRoles.inviteId,
roleId: userInviteRoles.roleId,
roleName: roles.name
})
.from(userInviteRoles)
.innerJoin(roles, eq(userInviteRoles.roleId, roles.roleId))
.where(
and(eq(roles.orgId, orgId), inArray(userInviteRoles.inviteId, inviteIds))
);
const rolesByInvite = new Map<
string,
{ roleId: number; roleName: string | null }[]
>();
for (const row of roleRows) {
const list = rolesByInvite.get(row.inviteId) ?? [];
list.push({ roleId: row.roleId, roleName: row.roleName });
rolesByInvite.set(row.inviteId, list);
}
return inviteRows.map((inv) => ({
inviteId: inv.inviteId,
email: inv.email,
expiresAt: inv.expiresAt,
roles: rolesByInvite.get(inv.inviteId) ?? []
}));
}
export type ListInvitationsResponse = {
invitations: NonNullable<Awaited<ReturnType<typeof queryInvitations>>>;
invitations: InvitationListRow[];
pagination: { total: number; limit: number; offset: number };
};
@@ -95,7 +137,7 @@ export async function listInvitations(
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(userInvites)
.where(sql`${userInvites.orgId} = ${orgId}`);
.where(eq(userInvites.orgId, orgId));
return response<ListInvitationsResponse>(res, {
data: {

View File

@@ -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)
};
});

View File

@@ -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<typeof accessControlsFormSchema>) {
if (values.roles.length === 0) {
@@ -255,53 +223,22 @@ export default function AccessControlsPage() {
</div>
)}
<FormField
control={form.control}
<OrgRolesTagField
form={form}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRoleTagIndex
}
setActiveTagIndex={
setActiveRoleTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={
allRoleOptions
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
disabled={loading}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>
{build === "saas"
? t(
"singleRolePerUserPlanNotice"
)
: t(
"singleRolePerUserEditionNotice"
)}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
label={t("roles")}
placeholder={t("accessRoleSelect2")}
allRoleOptions={allRoleOptions}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={paywallMessage}
loading={loading}
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
/>
{user.idpAutoProvision && (

View File

@@ -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<string | null>(
"internal"
@@ -89,19 +97,34 @@ export default function Page() {
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
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<AxiosResponse<InviteUserResponse>>(
`/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<AxiosResponse<InviteUserResponse>>(
`/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() {
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<OrgRolesTagField
form={internalForm}
name="roles"
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeInviteRoleTagIndex
}
setActiveTagIndex={
setActiveInviteRoleTagIndex
}
/>
{env.email.emailEnabled && (
@@ -764,52 +782,32 @@ export default function Page() {
)}
/>
<FormField
control={
googleAzureForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<OrgRolesTagField
form={googleAzureForm}
name="roles"
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/>
</form>
</Form>
@@ -909,52 +907,32 @@ export default function Page() {
)}
/>
<FormField
control={
genericOidcForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("role")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"accessRoleSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<OrgRolesTagField
form={genericOidcForm}
name="roles"
label={t("roles")}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/>
</form>
</Form>

View File

@@ -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: () => <span className="p-3">{t("role")}</span>
header: () => <span className="p-3">{t("role")}</span>,
cell: ({ row }) => (
<UserRoleBadges roleLabels={row.original.roleLabels} />
)
},
{
id: "dots",

View File

@@ -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<TFieldValues extends FieldValues> = {
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
name?: Path<TFieldValues>;
label: string;
placeholder: string;
allRoleOptions: Tag[];
supportsMultipleRolesPerUser: boolean;
showMultiRolePaywallMessage: boolean;
paywallMessage: string;
loading?: boolean;
activeTagIndex: number | null;
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
};
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
form,
name = "roles" as Path<TFieldValues>,
label,
placeholder,
allRoleOptions,
supportsMultipleRolesPerUser,
showMultiRolePaywallMessage,
paywallMessage,
loading = false,
activeTagIndex,
setActiveTagIndex
}: OrgRolesTagFieldProps<TFieldValues>) {
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 (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{label}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={placeholder}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={allRoleOptions}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
disabled={loading}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>{paywallMessage}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -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) {

View File

@@ -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 (
<div className="flex flex-wrap items-center gap-1">
{visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary">
{label}
</Badge>
))}
{overflow.length > 0 && (
<OverflowRolesPopover labels={overflow} />
)}
</div>
);
}
function OverflowRolesPopover({ labels }: { labels: string[] }) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
badgeVariants({ variant: "secondary" }),
"border-dashed"
)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
+{labels.length}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-auto max-w-xs p-2"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<ul className="space-y-1 text-sm">
{labels.map((label, i) => (
<li key={`${label}-${i}`}>{label}</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -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 (
<div className="flex flex-wrap items-center gap-1">
{visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary">
{label}
</Badge>
))}
{overflow.length > 0 && (
<OverflowRolesPopover labels={overflow} />
)}
</div>
);
}
function OverflowRolesPopover({ labels }: { labels: string[] }) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
badgeVariants({ variant: "secondary" }),
"border-dashed"
)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
+{labels.length}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-auto max-w-xs p-2"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<ul className="space-y-1 text-sm">
{labels.map((label, i) => (
<li key={`${label}-${i}`}>{label}</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
type UsersTableProps = {
users: UserRow[];
};