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

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

View File

@@ -3,18 +3,17 @@
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Checkbox } from "@app/components/ui/checkbox";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role";
@@ -67,8 +66,7 @@ export default function AccessControlsPage() {
);
const t = useTranslations();
const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } =
usePaidStatus();
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac);
const supportsMultipleRolesPerUser = isPaid;
const showMultiRolePaywallMessage =
@@ -131,40 +129,10 @@ export default function AccessControlsPage() {
text: role.name
}));
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
const prev = form.getValues("roles");
const nextValue =
typeof updater === "function" ? updater(prev) : updater;
const next = supportsMultipleRolesPerUser
? nextValue
: nextValue.length > 1
? [nextValue[nextValue.length - 1]]
: nextValue;
// In single-role mode, selecting the currently selected role can transiently
// emit an empty tag list from TagInput; keep the prior selection.
if (
!supportsMultipleRolesPerUser &&
next.length === 0 &&
prev.length > 0
) {
form.setValue("roles", [prev[prev.length - 1]], {
shouldDirty: true
});
return;
}
if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
form.setValue("roles", next, { shouldDirty: true });
}
const paywallMessage =
build === "saas"
? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice");
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
if (values.roles.length === 0) {
@@ -255,53 +223,22 @@ export default function AccessControlsPage() {
</div>
)}
<FormField
control={form.control}
<OrgRolesTagField
form={form}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRoleTagIndex
}
setActiveTagIndex={
setActiveRoleTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={
allRoleOptions
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
disabled={loading}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>
{build === "saas"
? t(
"singleRolePerUserPlanNotice"
)
: t(
"singleRolePerUserEditionNotice"
)}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
label={t("roles")}
placeholder={t("accessRoleSelect2")}
allRoleOptions={allRoleOptions}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={paywallMessage}
loading={loading}
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
/>
{user.idpAutoProvision && (

View File

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

View File

@@ -1,6 +1,5 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
@@ -21,13 +20,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import moment from "moment";
import { useRouter } from "next/navigation";
import UserRoleBadges from "@app/components/UserRoleBadges";
export type InvitationRow = {
id: string;
email: string;
expiresAt: string;
role: string;
roleId: number;
roleLabels: string[];
roleIds: number[];
};
type InvitationsTableProps = {
@@ -90,9 +90,13 @@ export default function InvitationsTable({
}
},
{
accessorKey: "role",
id: "roles",
accessorFn: (row) => row.roleLabels.join(", "),
friendlyName: t("role"),
header: () => <span className="p-3">{t("role")}</span>
header: () => <span className="p-3">{t("role")}</span>,
cell: ({ row }) => (
<UserRoleBadges roleLabels={row.original.roleLabels} />
)
},
{
id: "dots",

View File

@@ -0,0 +1,117 @@
"use client";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import type { Dispatch, SetStateAction } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
export type RoleTag = {
id: string;
text: string;
};
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
name?: Path<TFieldValues>;
label: string;
placeholder: string;
allRoleOptions: Tag[];
supportsMultipleRolesPerUser: boolean;
showMultiRolePaywallMessage: boolean;
paywallMessage: string;
loading?: boolean;
activeTagIndex: number | null;
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
};
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
form,
name = "roles" as Path<TFieldValues>,
label,
placeholder,
allRoleOptions,
supportsMultipleRolesPerUser,
showMultiRolePaywallMessage,
paywallMessage,
loading = false,
activeTagIndex,
setActiveTagIndex
}: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations();
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
const prev = form.getValues(name) as Tag[];
const nextValue =
typeof updater === "function" ? updater(prev) : updater;
const next = supportsMultipleRolesPerUser
? nextValue
: nextValue.length > 1
? [nextValue[nextValue.length - 1]]
: nextValue;
if (
!supportsMultipleRolesPerUser &&
next.length === 0 &&
prev.length > 0
) {
form.setValue(name, [prev[prev.length - 1]] as never, {
shouldDirty: true
});
return;
}
if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
form.setValue(name, next as never, { shouldDirty: true });
}
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{label}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={placeholder}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={allRoleOptions}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
disabled={loading}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>{paywallMessage}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
);
}

View File

@@ -32,15 +32,15 @@ type RegenerateInvitationFormProps = {
invitation: {
id: string;
email: string;
roleId: number;
role: string;
roleIds: number[];
roleLabels: string[];
} | null;
onRegenerate: (updatedInvitation: {
id: string;
email: string;
expiresAt: string;
role: string;
roleId: number;
roleLabels: string[];
roleIds: number[];
}) => void;
};
@@ -94,7 +94,7 @@ export default function RegenerateInvitationForm({
try {
const res = await api.post(`/org/${org.org.orgId}/create-invite`, {
email: invitation.email,
roleId: invitation.roleId,
roleIds: invitation.roleIds,
validHours,
sendEmail,
regenerate: true
@@ -127,9 +127,11 @@ export default function RegenerateInvitationForm({
onRegenerate({
id: invitation.id,
email: invitation.email,
expiresAt: res.data.data.expiresAt,
role: invitation.role,
roleId: invitation.roleId
expiresAt: new Date(
res.data.data.expiresAt
).toISOString(),
roleLabels: invitation.roleLabels,
roleIds: invitation.roleIds
});
}
} catch (error: any) {

View File

@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Badge, badgeVariants } from "@app/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
const MAX_ROLE_BADGES = 3;
export default function UserRoleBadges({
roleLabels
}: {
roleLabels: string[];
}) {
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
return (
<div className="flex flex-wrap items-center gap-1">
{visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary">
{label}
</Badge>
))}
{overflow.length > 0 && (
<OverflowRolesPopover labels={overflow} />
)}
</div>
);
}
function OverflowRolesPopover({ labels }: { labels: string[] }) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
badgeVariants({ variant: "secondary" }),
"border-dashed"
)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
+{labels.length}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-auto max-w-xs p-2"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<ul className="space-y-1 text-sm">
{labels.map((label, i) => (
<li key={`${label}-${i}`}>{label}</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -12,13 +12,6 @@ import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "@app/components/UsersDataTable";
import { useState, useEffect } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Badge, badgeVariants } from "@app/components/ui/badge";
import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
@@ -31,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "./IdpTypeBadge";
import UserRoleBadges from "./UserRoleBadges";
export type UserRow = {
id: string;
@@ -47,61 +41,6 @@ export type UserRow = {
isOwner: boolean;
};
const MAX_ROLE_BADGES = 3;
function UserRoleBadges({ roleLabels }: { roleLabels: string[] }) {
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
return (
<div className="flex flex-wrap items-center gap-1">
{visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary">
{label}
</Badge>
))}
{overflow.length > 0 && (
<OverflowRolesPopover labels={overflow} />
)}
</div>
);
}
function OverflowRolesPopover({ labels }: { labels: string[] }) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
badgeVariants({ variant: "secondary" }),
"border-dashed"
)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
+{labels.length}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-auto max-w-xs p-2"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<ul className="space-y-1 text-sm">
{labels.map((label, i) => (
<li key={`${label}-${i}`}>{label}</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
type UsersTableProps = {
users: UserRow[];
};