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, index,
integer, integer,
pgTable, pgTable,
primaryKey,
real, real,
serial, serial,
text, text,
@@ -467,12 +468,22 @@ export const userInvites = pgTable("userInvites", {
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
email: varchar("email").notNull(), email: varchar("email").notNull(),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
tokenHash: varchar("token").notNull(), tokenHash: varchar("token").notNull()
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" })
}); });
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", { export const resourcePincode = pgTable("resourcePincode", {
pincodeId: serial("pincodeId").primaryKey(), pincodeId: serial("pincodeId").primaryKey(),
resourceId: integer("resourceId") resourceId: integer("resourceId")
@@ -1048,6 +1059,7 @@ export type UserSite = InferSelectModel<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>; export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>; export type UserResource = InferSelectModel<typeof userResources>;
export type UserInvite = InferSelectModel<typeof userInvites>; export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserInviteRole = InferSelectModel<typeof userInviteRoles>;
export type UserOrg = InferSelectModel<typeof userOrgs>; export type UserOrg = InferSelectModel<typeof userOrgs>;
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>; export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>; export type ResourceSession = InferSelectModel<typeof resourceSessions>;

View File

@@ -3,6 +3,7 @@ import { InferSelectModel } from "drizzle-orm";
import { import {
index, index,
integer, integer,
primaryKey,
sqliteTable, sqliteTable,
text, text,
unique unique
@@ -804,12 +805,22 @@ export const userInvites = sqliteTable("userInvites", {
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
email: text("email").notNull(), email: text("email").notNull(),
expiresAt: integer("expiresAt").notNull(), expiresAt: integer("expiresAt").notNull(),
tokenHash: text("token").notNull(), tokenHash: text("token").notNull()
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" })
}); });
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", { export const resourcePincode = sqliteTable("resourcePincode", {
pincodeId: integer("pincodeId").primaryKey({ pincodeId: integer("pincodeId").primaryKey({
autoIncrement: true autoIncrement: true
@@ -1152,6 +1163,7 @@ export type UserSite = InferSelectModel<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>; export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>; export type UserResource = InferSelectModel<typeof userResources>;
export type UserInvite = InferSelectModel<typeof userInvites>; export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserInviteRole = InferSelectModel<typeof userInviteRoles>;
export type UserOrg = InferSelectModel<typeof userOrgs>; export type UserOrg = InferSelectModel<typeof userOrgs>;
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>; export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>; export type ResourceSession = InferSelectModel<typeof resourceSessions>;

View File

@@ -19,15 +19,22 @@ import { FeatureId } from "@server/lib/billing";
export async function assignUserToOrg( export async function assignUserToOrg(
org: Org, org: Org,
values: typeof userOrgs.$inferInsert, values: typeof userOrgs.$inferInsert,
roleId: number, roleIds: number[],
trx: Transaction | typeof db = db 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(); const [userOrg] = await trx.insert(userOrgs).values(values).returning();
await trx.insert(userOrgRoles).values({ await trx.insert(userOrgRoles).values(
userId: userOrg.userId, uniqueRoleIds.map((roleId) => ({
orgId: userOrg.orgId, userId: userOrg.userId,
roleId 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 // 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) { if (org.billingOrgId) {

View File

@@ -623,9 +623,7 @@ export async function validateOidcCallback(
if (orgsToAdd.length > 0) { if (orgsToAdd.length > 0) {
for (const org of orgsToAdd) { for (const org of orgsToAdd) {
const [initialRoleId, ...additionalRoleIds] = if (org.roleIds.length === 0) {
org.roleIds;
if (!initialRoleId) {
continue; continue;
} }
@@ -641,17 +639,9 @@ export async function validateOidcCallback(
userId: userId!, userId: userId!,
autoProvisioned: true, autoProvisioned: true,
}, },
initialRoleId, org.roleIds,
trx 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 { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, orgs, UserOrg } from "@server/db"; import { db, orgs } from "@server/db";
import { roles, userInvites, userOrgs, users } from "@server/db"; import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
import { eq, and, inArray, ne } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -141,17 +141,34 @@ export async function acceptInvite(
); );
} }
let roleId: number; const inviteRoleRows = await db
// get the role to make sure it exists .select({ roleId: userInviteRoles.roleId })
const existingRole = await db .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() .select()
.from(roles) .from(roles)
.where(eq(roles.roleId, existingInvite.roleId)) .where(
.limit(1); and(
if (existingRole.length) { eq(roles.orgId, existingInvite.orgId),
roleId = existingRole[0].roleId; inArray(roles.roleId, inviteRoleIds)
} else { )
// TODO: use the default role on the org instead of failing );
if (existingRoles.length !== inviteRoleIds.length) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
@@ -167,7 +184,7 @@ export async function acceptInvite(
userId: existingUser[0].userId, userId: existingUser[0].userId,
orgId: existingInvite.orgId orgId: existingInvite.orgId
}, },
existingInvite.roleId, inviteRoleIds,
trx trx
); );

View File

@@ -6,8 +6,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { db, orgs, UserOrg } from "@server/db"; import { db, orgs } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
@@ -15,21 +15,43 @@ import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build"; import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; 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 { assignUserToOrg } from "@server/lib/userOrg";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
}); });
const bodySchema = z.strictObject({ const bodySchema = z
email: z.string().email().toLowerCase().optional(), .strictObject({
username: z.string().nonempty().toLowerCase(), email: z.string().email().toLowerCase().optional(),
name: z.string().optional(), username: z.string().nonempty().toLowerCase(),
type: z.enum(["internal", "oidc"]).optional(), name: z.string().optional(),
idpId: z.number().optional(), type: z.enum(["internal", "oidc"]).optional(),
roleId: z.number() 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 = {}; export type CreateOrgUserResponse = {};
@@ -78,7 +100,8 @@ export async function createOrgUser(
} }
const { orgId } = parsedParams.data; 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") { if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.USERS); 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") { if (type === "internal") {
return next( return next(
createHttpError( 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 const [org] = await db
.select() .select()
.from(orgs) .from(orgs)
@@ -228,7 +272,7 @@ export async function createOrgUser(
userId: existingUser.userId, userId: existingUser.userId,
autoProvisioned: false, autoProvisioned: false,
}, },
role.roleId, uniqueRoleIds,
trx trx
); );
} else { } else {
@@ -255,7 +299,7 @@ export async function createOrgUser(
userId: newUser.userId, userId: newUser.userId,
autoProvisioned: false, autoProvisioned: false,
}, },
role.roleId, uniqueRoleIds,
trx trx
); );
} }

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { orgs, roles, userInvites, userOrgs, users } from "@server/db"; import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -18,22 +18,44 @@ import { OpenAPITags, registry } from "@server/openApi";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { FeatureId } from "@server/lib/billing";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import cache from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const inviteUserParamsSchema = z.strictObject({ const inviteUserParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const inviteUserBodySchema = z.strictObject({ const inviteUserBodySchema = z
email: z.email().toLowerCase(), .strictObject({
roleId: z.number(), email: z.email().toLowerCase(),
validHours: z.number().gt(0).lte(168), roleIds: z.array(z.number().int().positive()).min(1).optional(),
sendEmail: z.boolean().optional(), roleId: z.number().int().positive().optional(),
regenerate: z.boolean().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 = { export type InviteUserResponse = {
inviteLink: string; inviteLink: string;
@@ -88,7 +110,7 @@ export async function inviteUser(
const { const {
email, email,
validHours, validHours,
roleId, roleIds: uniqueRoleIds,
sendEmail: doEmail, sendEmail: doEmail,
regenerate regenerate
} = parsedBody.data; } = parsedBody.data;
@@ -105,14 +127,30 @@ export async function inviteUser(
); );
} }
// Validate that the roleId belongs to the target organization const supportsMultiRole = await isLicensedOrSubscribed(
const [role] = await db orgId,
.select() tierMatrix[TierFeature.FullRbac]
.from(roles) );
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) if (!supportsMultiRole && uniqueRoleIds.length > 1) {
.limit(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( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
@@ -191,7 +229,8 @@ export async function inviteUser(
} }
if (existingInvite.length) { if (existingInvite.length) {
const attempts = (await cache.get<number>(email)) || 0; const attempts =
(await cache.get<number>("regenerateInvite:" + email)) || 0;
if (attempts >= 3) { if (attempts >= 3) {
return next( return next(
createHttpError( createHttpError(
@@ -273,9 +312,11 @@ export async function inviteUser(
orgId, orgId,
email, email,
expiresAt, expiresAt,
tokenHash, tokenHash
roleId
}); });
await trx.insert(userInviteRoles).values(
uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
);
}); });
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; 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 { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { sql } from "drizzle-orm"; import { sql, eq, and, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -29,24 +29,66 @@ const listInvitationsQuerySchema = z.strictObject({
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative())
}); });
async function queryInvitations(orgId: string, limit: number, offset: number) { export type InvitationListRow = {
return await db 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({ .select({
inviteId: userInvites.inviteId, inviteId: userInvites.inviteId,
email: userInvites.email, email: userInvites.email,
expiresAt: userInvites.expiresAt, expiresAt: userInvites.expiresAt
roleId: userInvites.roleId,
roleName: roles.name
}) })
.from(userInvites) .from(userInvites)
.leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`) .where(eq(userInvites.orgId, orgId))
.where(sql`${userInvites.orgId} = ${orgId}`)
.limit(limit) .limit(limit)
.offset(offset); .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 = { export type ListInvitationsResponse = {
invitations: NonNullable<Awaited<ReturnType<typeof queryInvitations>>>; invitations: InvitationListRow[];
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
@@ -95,7 +137,7 @@ export async function listInvitations(
const [{ count }] = await db const [{ count }] = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(userInvites) .from(userInvites)
.where(sql`${userInvites.orgId} = ${orgId}`); .where(eq(userInvites.orgId, orgId));
return response<ListInvitationsResponse>(res, { return response<ListInvitationsResponse>(res, {
data: { data: {

View File

@@ -29,9 +29,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
let invitations: { let invitations: {
inviteId: string; inviteId: string;
email: string; email: string;
expiresAt: string; expiresAt: number;
roleId: number; roles: { roleId: number; roleName: string | null }[];
roleName?: string;
}[] = []; }[] = [];
let hasInvitations = false; let hasInvitations = false;
@@ -66,12 +65,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
} }
const invitationRows: InvitationRow[] = invitations.map((invite) => { const invitationRows: InvitationRow[] = invitations.map((invite) => {
const names = invite.roles
.map((r) => r.roleName || t("accessRoleUnknown"))
.filter(Boolean);
return { return {
id: invite.inviteId, id: invite.inviteId,
email: invite.email, email: invite.email,
expiresAt: new Date(Number(invite.expiresAt)).toISOString(), expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
role: invite.roleName || t("accessRoleUnknown"), roleLabels: names,
roleId: invite.roleId roleIds: invite.roles.map((r) => r.roleId)
}; };
}); });

View File

@@ -3,18 +3,17 @@
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Checkbox } from "@app/components/ui/checkbox"; 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 { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
@@ -67,8 +66,7 @@ export default function AccessControlsPage() {
); );
const t = useTranslations(); const t = useTranslations();
const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } = const { isPaidUser } = usePaidStatus();
usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac); const isPaid = isPaidUser(tierMatrix.fullRbac);
const supportsMultipleRolesPerUser = isPaid; const supportsMultipleRolesPerUser = isPaid;
const showMultiRolePaywallMessage = const showMultiRolePaywallMessage =
@@ -131,40 +129,10 @@ export default function AccessControlsPage() {
text: role.name text: role.name
})); }));
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) { const paywallMessage =
const prev = form.getValues("roles"); build === "saas"
const nextValue = ? t("singleRolePerUserPlanNotice")
typeof updater === "function" ? updater(prev) : updater; : t("singleRolePerUserEditionNotice");
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 });
}
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) { async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
if (values.roles.length === 0) { if (values.roles.length === 0) {
@@ -255,53 +223,22 @@ export default function AccessControlsPage() {
</div> </div>
)} )}
<FormField <OrgRolesTagField
control={form.control} form={form}
name="roles" name="roles"
render={({ field }) => ( label={t("roles")}
<FormItem className="flex flex-col items-start"> placeholder={t("accessRoleSelect2")}
<FormLabel>{t("roles")}</FormLabel> allRoleOptions={allRoleOptions}
<FormControl> supportsMultipleRolesPerUser={
<TagInput supportsMultipleRolesPerUser
{...field} }
activeTagIndex={ showMultiRolePaywallMessage={
activeRoleTagIndex showMultiRolePaywallMessage
} }
setActiveTagIndex={ paywallMessage={paywallMessage}
setActiveRoleTagIndex loading={loading}
} activeTagIndex={activeRoleTagIndex}
placeholder={t( setActiveTagIndex={setActiveRoleTagIndex}
"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>
)}
/> />
{user.idpAutoProvision && ( {user.idpAutoProvision && (

View File

@@ -32,7 +32,7 @@ import {
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -49,6 +49,7 @@ import { build } from "@server/build";
import Image from "next/image"; import Image from "next/image";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
type UserType = "internal" | "oidc"; type UserType = "internal" | "oidc";
@@ -76,7 +77,14 @@ export default function Page() {
const api = createApiClient({ env }); const api = createApiClient({ env });
const t = useTranslations(); 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>( const [selectedOption, setSelectedOption] = useState<string | null>(
"internal" "internal"
@@ -89,19 +97,34 @@ export default function Page() {
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [userOptions, setUserOptions] = useState<UserOption[]>([]); const [userOptions, setUserOptions] = useState<UserOption[]>([]);
const [dataLoaded, setDataLoaded] = useState(false); 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({ const internalFormSchema = z.object({
email: z.email({ message: t("emailInvalid") }), email: z.email({ message: t("emailInvalid") }),
validForHours: z validForHours: z
.string() .string()
.min(1, { message: t("inviteValidityDuration") }), .min(1, { message: t("inviteValidityDuration") }),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) roles: roleTagsFieldSchema
}); });
const googleAzureFormSchema = z.object({ const googleAzureFormSchema = z.object({
email: z.email({ message: t("emailInvalid") }), email: z.email({ message: t("emailInvalid") }),
name: z.string().optional(), name: z.string().optional(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) roles: roleTagsFieldSchema
}); });
const genericOidcFormSchema = z.object({ const genericOidcFormSchema = z.object({
@@ -111,7 +134,7 @@ export default function Page() {
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
name: z.string().optional(), name: z.string().optional(),
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) roles: roleTagsFieldSchema
}); });
const formatIdpType = (type: string) => { const formatIdpType = (type: string) => {
@@ -166,12 +189,22 @@ export default function Page() {
{ hours: 168, name: t("day", { count: 7 }) } { 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({ const internalForm = useForm({
resolver: zodResolver(internalFormSchema), resolver: zodResolver(internalFormSchema),
defaultValues: { defaultValues: {
email: "", email: "",
validForHours: "72", validForHours: "72",
roleId: "" roles: [] as { id: string; text: string }[]
} }
}); });
@@ -180,7 +213,7 @@ export default function Page() {
defaultValues: { defaultValues: {
email: "", email: "",
name: "", name: "",
roleId: "" roles: [] as { id: string; text: string }[]
} }
}); });
@@ -190,7 +223,7 @@ export default function Page() {
username: "", username: "",
email: "", email: "",
name: "", name: "",
roleId: "" roles: [] as { id: string; text: string }[]
} }
}); });
@@ -305,16 +338,17 @@ export default function Page() {
) { ) {
setLoading(true); setLoading(true);
const res = await api const roleIds = values.roles.map((r) => parseInt(r.id, 10));
.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`, const res = await api.post<AxiosResponse<InviteUserResponse>>(
{ `/org/${orgId}/create-invite`,
email: values.email, {
roleId: parseInt(values.roleId), email: values.email,
validHours: parseInt(values.validForHours), roleIds,
sendEmail: sendEmail validHours: parseInt(values.validForHours),
} as InviteUserBody sendEmail
) }
)
.catch((e) => { .catch((e) => {
if (e.response?.status === 409) { if (e.response?.status === 409) {
toast({ toast({
@@ -358,6 +392,8 @@ export default function Page() {
setLoading(true); setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
.put(`/org/${orgId}/user`, { .put(`/org/${orgId}/user`, {
username: values.email, // Use email as username for Google/Azure username: values.email, // Use email as username for Google/Azure
@@ -365,7 +401,7 @@ export default function Page() {
name: values.name, name: values.name,
type: "oidc", type: "oidc",
idpId: selectedUserOption.idpId, idpId: selectedUserOption.idpId,
roleId: parseInt(values.roleId) roleIds
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
@@ -400,6 +436,8 @@ export default function Page() {
setLoading(true); setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
.put(`/org/${orgId}/user`, { .put(`/org/${orgId}/user`, {
username: values.username, username: values.username,
@@ -407,7 +445,7 @@ export default function Page() {
name: values.name, name: values.name,
type: "oidc", type: "oidc",
idpId: selectedUserOption.idpId, idpId: selectedUserOption.idpId,
roleId: parseInt(values.roleId) roleIds
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
@@ -575,52 +613,32 @@ export default function Page() {
)} )}
/> />
<FormField <OrgRolesTagField
control={ form={internalForm}
internalForm.control name="roles"
} label={t("roles")}
name="roleId" placeholder={t(
render={({ field }) => ( "accessRoleSelect2"
<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>
)} )}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeInviteRoleTagIndex
}
setActiveTagIndex={
setActiveInviteRoleTagIndex
}
/> />
{env.email.emailEnabled && ( {env.email.emailEnabled && (
@@ -764,52 +782,32 @@ export default function Page() {
)} )}
/> />
<FormField <OrgRolesTagField
control={ form={googleAzureForm}
googleAzureForm.control name="roles"
} label={t("roles")}
name="roleId" placeholder={t(
render={({ field }) => ( "accessRoleSelect2"
<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>
)} )}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/> />
</form> </form>
</Form> </Form>
@@ -909,52 +907,32 @@ export default function Page() {
)} )}
/> />
<FormField <OrgRolesTagField
control={ form={genericOidcForm}
genericOidcForm.control name="roles"
} label={t("roles")}
name="roleId" placeholder={t(
render={({ field }) => ( "accessRoleSelect2"
<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>
)} )}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/> />
</form> </form>
</Form> </Form>

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
@@ -21,13 +20,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import moment from "moment"; import moment from "moment";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import UserRoleBadges from "@app/components/UserRoleBadges";
export type InvitationRow = { export type InvitationRow = {
id: string; id: string;
email: string; email: string;
expiresAt: string; expiresAt: string;
role: string; roleLabels: string[];
roleId: number; roleIds: number[];
}; };
type InvitationsTableProps = { type InvitationsTableProps = {
@@ -90,9 +90,13 @@ export default function InvitationsTable({
} }
}, },
{ {
accessorKey: "role", id: "roles",
accessorFn: (row) => row.roleLabels.join(", "),
friendlyName: t("role"), 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", 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: { invitation: {
id: string; id: string;
email: string; email: string;
roleId: number; roleIds: number[];
role: string; roleLabels: string[];
} | null; } | null;
onRegenerate: (updatedInvitation: { onRegenerate: (updatedInvitation: {
id: string; id: string;
email: string; email: string;
expiresAt: string; expiresAt: string;
role: string; roleLabels: string[];
roleId: number; roleIds: number[];
}) => void; }) => void;
}; };
@@ -94,7 +94,7 @@ export default function RegenerateInvitationForm({
try { try {
const res = await api.post(`/org/${org.org.orgId}/create-invite`, { const res = await api.post(`/org/${org.org.orgId}/create-invite`, {
email: invitation.email, email: invitation.email,
roleId: invitation.roleId, roleIds: invitation.roleIds,
validHours, validHours,
sendEmail, sendEmail,
regenerate: true regenerate: true
@@ -127,9 +127,11 @@ export default function RegenerateInvitationForm({
onRegenerate({ onRegenerate({
id: invitation.id, id: invitation.id,
email: invitation.email, email: invitation.email,
expiresAt: res.data.data.expiresAt, expiresAt: new Date(
role: invitation.role, res.data.data.expiresAt
roleId: invitation.roleId ).toISOString(),
roleLabels: invitation.roleLabels,
roleIds: invitation.roleIds
}); });
} }
} catch (error: any) { } 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 { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "@app/components/UsersDataTable"; import { UsersDataTable } from "@app/components/UsersDataTable";
import { useState, useEffect } from "react"; 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 ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
@@ -31,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import IdpTypeBadge from "./IdpTypeBadge"; import IdpTypeBadge from "./IdpTypeBadge";
import UserRoleBadges from "./UserRoleBadges";
export type UserRow = { export type UserRow = {
id: string; id: string;
@@ -47,61 +41,6 @@ export type UserRow = {
isOwner: boolean; 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 = { type UsersTableProps = {
users: UserRow[]; users: UserRow[];
}; };