mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-30 14:36:46 +00:00
support multi role on create user and invites
This commit is contained in:
@@ -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>;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
117
src/components/OrgRolesTagField.tsx
Normal file
117
src/components/OrgRolesTagField.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
69
src/components/UserRoleBadges.tsx
Normal file
69
src/components/UserRoleBadges.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user