mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-31 15:06:42 +00:00
support multi role on create user and invites
This commit is contained in:
@@ -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)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
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: {
|
||||
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) {
|
||||
|
||||
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 { 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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user