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

@@ -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[];
};