♻️ replace roles & user selectors in machines & create user

This commit is contained in:
Fred KISSIE
2026-04-28 05:08:20 +02:00
parent 27b2ec309d
commit ddaa9c32a7
11 changed files with 211 additions and 337 deletions

View File

@@ -1,44 +1,40 @@
"use client"; "use client";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel
FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Checkbox } from "@app/components/ui/checkbox"; import { useEnvContext } from "@app/hooks/useEnvContext";
import OrgRolesTagField from "@app/components/OrgRolesTagField"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios"; import { build } from "@server/build";
import { useEffect, useState } from "react"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes";
import { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import { useActionState, useEffect } 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 { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
const accessControlsFormSchema = z.object({ const accessControlsFormSchema = z.object({
username: z.string(), username: z.string(),
@@ -59,12 +55,6 @@ export default function AccessControlsPage() {
const { orgId } = useParams(); const { orgId } = useParams();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.fullRbac); const isPaid = isPaidUser(tierMatrix.fullRbac);
@@ -97,44 +87,21 @@ export default function AccessControlsPage() {
text: r.name text: r.name
})) }))
); );
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
fetchRoles();
form.setValue("autoProvisioned", user.autoProvisioned || false); form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []); }, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]);
const allRoleOptions = roles.map((role) => ({
id: role.roleId.toString(),
text: role.name
}));
const paywallMessage = const paywallMessage =
build === "saas" build === "saas"
? t("singleRolePerUserPlanNotice") ? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice"); : t("singleRolePerUserEditionNotice");
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) { const [, action, isSubmitting] = useActionState(onSubmit, null);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
if (values.roles.length === 0) { if (values.roles.length === 0) {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -144,7 +111,6 @@ export default function AccessControlsPage() {
return; return;
} }
setLoading(true);
try { try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser const updateRoleRequest = supportsMultipleRolesPerUser
@@ -184,7 +150,6 @@ export default function AccessControlsPage() {
) )
}); });
} }
setLoading(false);
} }
return ( return (
@@ -203,7 +168,7 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} action={action}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
@@ -226,9 +191,7 @@ export default function AccessControlsPage() {
<OrgRolesTagField <OrgRolesTagField
form={form} form={form}
name="roles" name="roles"
label={t("roles")} orgId={orgId as string}
placeholder={t("accessRoleSelect2")}
allRoleOptions={allRoleOptions}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -236,9 +199,6 @@ export default function AccessControlsPage() {
showMultiRolePaywallMessage showMultiRolePaywallMessage
} }
paywallMessage={paywallMessage} paywallMessage={paywallMessage}
loading={loading}
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
/> />
{user.idpAutoProvision && ( {user.idpAutoProvision && (
@@ -277,8 +237,8 @@ export default function AccessControlsPage() {
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button
type="submit" type="submit"
loading={loading} loading={isSubmitting}
disabled={loading} disabled={isSubmitting}
form="access-controls-form" form="access-controls-form"
> >
{t("accessControlsSubmit")} {t("accessControlsSubmit")}

View File

@@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useState } from "react"; import { useActionState, useState } from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -91,7 +91,7 @@ export default function Page() {
"internal" "internal"
); );
const [inviteLink, setInviteLink] = useState<string | null>(null); const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1); const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]); const [idps, setIdps] = useState<IdpOption[]>([]);
@@ -311,10 +311,29 @@ export default function Page() {
setUserOptions(options); setUserOptions(options);
}, [idps, t]); }, [idps, t]);
async function onSubmitInternal( const [, submitInternalAction, isSubmittingInternal] = useActionState(
values: z.infer<typeof internalFormSchema> onSubmitInternal,
) { null
setLoading(true); );
const [, submitGoogleAzureAction, isSubmittingGoogleAzure] = useActionState(
onSubmitGoogleAzure,
null
);
const [, submitGenericOidcAction, isSubmittingGenericOidc] = useActionState(
onSubmitGenericOidc,
null
);
const loading =
isSubmittingInternal ||
isSubmittingGoogleAzure ||
isSubmittingGenericOidc;
async function onSubmitInternal() {
const isValid = await internalForm.trigger();
if (!isValid) return;
const values = internalForm.getValues();
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
@@ -357,25 +376,24 @@ export default function Page() {
setExpiresInDays(parseInt(values.validForHours) / 24); setExpiresInDays(parseInt(values.validForHours) / 24);
} }
setLoading(false);
} }
async function onSubmitGoogleAzure( async function onSubmitGoogleAzure() {
values: z.infer<typeof googleAzureFormSchema> const isValid = await googleAzureForm.trigger();
) { if (!isValid) return;
const values = googleAzureForm.getValues();
const selectedUserOption = userOptions.find( const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption (opt) => opt.id === selectedOption
); );
if (!selectedUserOption?.idpId) return; if (!selectedUserOption?.idpId) return;
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); 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,
email: values.email || undefined, email: values.email || undefined,
name: values.name, name: values.name,
type: "oidc", type: "oidc",
@@ -401,20 +419,19 @@ export default function Page() {
}); });
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
} }
setLoading(false);
} }
async function onSubmitGenericOidc( async function onSubmitGenericOidc() {
values: z.infer<typeof genericOidcFormSchema> const isValid = await genericOidcForm.trigger();
) { if (!isValid) return;
const values = genericOidcForm.getValues();
const selectedUserOption = userOptions.find( const selectedUserOption = userOptions.find(
(opt) => opt.id === selectedOption (opt) => opt.id === selectedOption
); );
if (!selectedUserOption?.idpId) return; if (!selectedUserOption?.idpId) return;
setLoading(true);
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api const res = await api
@@ -445,8 +462,6 @@ export default function Page() {
}); });
router.push(`/${orgId}/settings/access/users`); router.push(`/${orgId}/settings/access/users`);
} }
setLoading(false);
} }
return ( return (
@@ -513,9 +528,9 @@ export default function Page() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...internalForm}> <Form {...internalForm}>
<form <form
onSubmit={internalForm.handleSubmit( action={
onSubmitInternal submitInternalAction
)} }
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -595,13 +610,7 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={internalForm} form={internalForm}
name="roles" name="roles"
label={t("roles")} orgId={orgId as string}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -611,13 +620,6 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeInviteRoleTagIndex
}
setActiveTagIndex={
setActiveInviteRoleTagIndex
}
/> />
{env.email.emailEnabled && ( {env.email.emailEnabled && (
@@ -712,9 +714,9 @@ export default function Page() {
})() && ( })() && (
<Form {...googleAzureForm}> <Form {...googleAzureForm}>
<form <form
onSubmit={googleAzureForm.handleSubmit( action={
onSubmitGoogleAzure submitGoogleAzureAction
)} }
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -763,13 +765,7 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={googleAzureForm} form={googleAzureForm}
name="roles" name="roles"
label={t("roles")} orgId={orgId as string}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -779,13 +775,6 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/> />
</form> </form>
</Form> </Form>
@@ -808,9 +797,9 @@ export default function Page() {
})() && ( })() && (
<Form {...genericOidcForm}> <Form {...genericOidcForm}>
<form <form
onSubmit={genericOidcForm.handleSubmit( action={
onSubmitGenericOidc submitGenericOidcAction
)} }
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
@@ -888,13 +877,7 @@ export default function Page() {
<OrgRolesTagField <OrgRolesTagField
form={genericOidcForm} form={genericOidcForm}
name="roles" name="roles"
label={t("roles")} orgId={orgId as string}
placeholder={t(
"accessRoleSelect2"
)}
allRoleOptions={
allRoleOptions
}
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
@@ -904,13 +887,6 @@ export default function Page() {
paywallMessage={ paywallMessage={
invitePaywallMessage invitePaywallMessage
} }
loading={loading}
activeTagIndex={
activeOidcRoleTagIndex
}
setActiveTagIndex={
setActiveOidcRoleTagIndex
}
/> />
</form> </form>
</Form> </Form>

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<CredenzaContent <CredenzaContent
className={cn( className={cn(
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0", "flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
className className
)} )}
{...props} {...props}

View File

@@ -61,6 +61,7 @@ import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import CertificateStatus from "@app/components/CertificateStatus"; import CertificateStatus from "@app/components/CertificateStatus";
import { UsersSelector } from "./users-selector"; import { UsersSelector } from "./users-selector";
import { RolesSelector } from "./roles-selector";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -1477,40 +1478,22 @@ export function InternalResourceForm({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel> <FormLabel>{t("roles")}</FormLabel>
<FormControl> <FormControl>
<TagInput <RolesSelector
{...field} selectedRoles={
activeTagIndex={ field.value ?? []
activeRolesTagIndex
} }
setActiveTagIndex={ orgId={orgId}
setActiveRolesTagIndex onSelectRoles={(
} newUsers
placeholder={t( ) => {
"accessRoleSelect2"
)}
size="sm"
tags={
form.getValues()
.roles ?? []
}
setTags={(newRoles) =>
form.setValue( form.setValue(
"roles", "roles",
newRoles as [ newUsers as [
Tag, Tag,
...Tag[] ...Tag[]
] ]
) );
} }}
enableAutocomplete
autocompleteOptions={
allRoles
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -1523,45 +1506,7 @@ export function InternalResourceForm({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel> <FormLabel>{t("users")}</FormLabel>
{/* <FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
form.getValues()
.users ?? []
}
size="sm"
setTags={(newUsers) =>
form.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
)
}
enableAutocomplete={true}
autocompleteOptions={
allUsers
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl> */}
<UsersSelector <UsersSelector
{...field}
selectedUsers={ selectedUsers={
field.value ?? [] field.value ?? []
} }
@@ -1590,7 +1535,6 @@ export function InternalResourceForm({
{t("machineClients")} {t("machineClients")}
</FormLabel> </FormLabel>
<MachinesSelector <MachinesSelector
{...field}
selectedMachines={ selectedMachines={
field.value ?? [] field.value ?? []
} }
@@ -1604,73 +1548,6 @@ export function InternalResourceForm({
); );
}} }}
/> />
{/* <Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between w-full",
"text-muted-foreground pl-1.5 cursor-text"
)}
>
<span
className={cn(
"inline-flex items-center gap-1",
"overflow-x-auto"
)}
>
{(
field.value ??
[]
).map(
(
client
) => (
<span
key={
client.clientId
}
className={cn(
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
"py-1 px-1.5 text-xs"
)}
>
{
client.name
}
</span>
)
)}
<span className="pl-1 font-normal">
{t(
"accessClientSelect"
)}
</span>
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<MachinesSelector
selectedMachines={
field.value ??
[]
}
orgId={orgId}
onSelectMachines={(
machines
) => {
form.setValue(
"clients",
machines
);
}}
/>
</PopoverContent>
</Popover> */}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -8,51 +8,42 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Dispatch, SetStateAction } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
export type RoleTag = { import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
id: string; import { RolesSelector, type SelectedRole } from "./roles-selector";
text: string;
};
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = { type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">; form: Pick<
UseFormReturn<TFieldValues>,
"control" | "getValues" | "setValue"
>;
orgId: string;
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
name?: Path<TFieldValues>; name?: Path<TFieldValues>;
label: string; label?: string;
placeholder: string;
allRoleOptions: Tag[];
supportsMultipleRolesPerUser: boolean; supportsMultipleRolesPerUser: boolean;
showMultiRolePaywallMessage: boolean; showMultiRolePaywallMessage: boolean;
paywallMessage: string; paywallMessage: string;
loading?: boolean; disabled?: boolean;
activeTagIndex: number | null;
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
}; };
export default function OrgRolesTagField<TFieldValues extends FieldValues>({ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
form, form,
name = "roles" as Path<TFieldValues>, name = "roles" as Path<TFieldValues>,
label, label,
placeholder, orgId,
allRoleOptions,
supportsMultipleRolesPerUser, supportsMultipleRolesPerUser,
showMultiRolePaywallMessage, showMultiRolePaywallMessage,
paywallMessage, paywallMessage,
loading = false, disabled
activeTagIndex,
setActiveTagIndex
}: OrgRolesTagFieldProps<TFieldValues>) { }: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations(); const t = useTranslations();
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) { function setRoleTags(nextValue: SelectedRole[]) {
const prev = form.getValues(name) as Tag[]; const prev = form.getValues(name) as SelectedRole[];
const nextValue =
typeof updater === "function" ? updater(prev) : updater;
const next = supportsMultipleRolesPerUser const next = supportsMultipleRolesPerUser
? nextValue ? nextValue
: nextValue.length > 1 : nextValue.length > 1
@@ -88,22 +79,13 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
name={name} name={name}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{label}</FormLabel> <FormLabel>{label ?? t("roles")}</FormLabel>
<FormControl> <FormControl>
<TagInput <RolesSelector
{...field} orgId={orgId}
activeTagIndex={activeTagIndex} selectedRoles={field.value ?? []}
setActiveTagIndex={setActiveTagIndex} onSelectRoles={setRoleTags}
placeholder={placeholder} disabled={disabled}
size="sm"
tags={field.value}
setTags={setRoleTags}
enableAutocomplete={true}
autocompleteOptions={allRoleOptions}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
disabled={loading}
/> />
</FormControl> </FormControl>
{showMultiRolePaywallMessage && ( {showMultiRolePaywallMessage && (

View File

@@ -21,6 +21,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
onChange: (newValue: Array<T>) => void; onChange: (newValue: Array<T>) => void;
onSearch: (query: string) => void; onSearch: (query: string) => void;
ref?: Ref<HTMLButtonElement>; ref?: Ref<HTMLButtonElement>;
disabled?: boolean;
}; };
export function MultiSelectContent<T extends TagValue>({ export function MultiSelectContent<T extends TagValue>({
@@ -40,8 +41,7 @@ export function MultiSelectContent<T extends TagValue>({
value={searchQuery} value={searchQuery}
onValueChange={onSearch} onValueChange={onSearch}
/> />
{/* FIXME: why isn't this list scrolling ????? */} <CommandList>
<CommandList className="scroll-py-0 max-h-20">
<CommandEmpty>{emptyPlaceholder}</CommandEmpty> <CommandEmpty>{emptyPlaceholder}</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => ( {options.map((option) => (

View File

@@ -1,17 +1,16 @@
import { buttonVariants } from "@app/components/ui/button";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { Button, buttonVariants } from "@app/components/ui/button";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ChevronDownIcon, XIcon } from "lucide-react"; import { ChevronDownIcon, XIcon } from "lucide-react";
import { import {
type TagValue,
type MultiSelectTagsProps, type MultiSelectTagsProps,
type TagValue,
MultiSelectContent MultiSelectContent
} from "./multi-select-content"; } from "./multi-select-content";
import { useState } from "react";
export interface MultiSelectInputProps< export interface MultiSelectInputProps<
T extends TagValue T extends TagValue
@@ -36,7 +35,8 @@ export function MultiSelectTagInput<T extends TagValue>({
}), }),
"justify-between w-full inline-flex", "justify-between w-full inline-flex",
"text-muted-foreground pl-1.5 cursor-text", "text-muted-foreground pl-1.5 cursor-text",
"hover:bg-transparent hover:text-muted-foreground" "hover:bg-transparent hover:text-muted-foreground",
props.disabled && "pointer-events-none opacity-50"
)} )}
> >
<span <span
@@ -57,6 +57,7 @@ export function MultiSelectTagInput<T extends TagValue>({
{option.text} {option.text}
<button <button
className="p-0.5 flex-none cursor-pointer" className="p-0.5 flex-none cursor-pointer"
type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
let newValues = []; let newValues = [];

View File

@@ -1 +1,64 @@
// TODO import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedRole = { id: string; text: string };
export type RolesSelectorProps = {
orgId: string;
selectedRoles?: SelectedRole[];
onSelectRoles: (roles: SelectedRole[]) => void;
disabled?: boolean
};
export function RolesSelector({
orgId,
selectedRoles = [],
onSelectRoles,
disabled
}: RolesSelectorProps) {
const t = useTranslations();
const [roleSearchQuery, setRoleSearchQuery] = useState("");
const [debouncedValue] = useDebounce(roleSearchQuery, 150);
const perPage = 7;
const { data: roles = [] } = useQuery(
orgQueries.roles({ orgId, perPage, query: debouncedValue })
);
// always include the selected roles in the list (if the user isn't searching)
const rolesShown = useMemo(() => {
const allRoles: Array<SelectedRole> = roles.map((r) => ({
id: r.roleId.toString(),
text: r.name
}));
if (debouncedValue.trim().length === 0) {
for (const role of selectedRoles) {
if (!allRoles.find((r) => r.id === role.id)) {
allRoles.unshift(role);
}
}
}
return allRoles;
}, [roles, selectedRoles, debouncedValue]);
return (
<MultiSelectTagInput
buttonText={t("selectRole")}
searchPlaceholder={t("search")}
emptyPlaceholder={t("roles")}
searchQuery={roleSearchQuery}
onSearch={setRoleSearchQuery}
options={rolesShown}
value={selectedRoles}
onChange={onSelectRoles}
disabled={disabled}
/>
);
}

View File

@@ -115,7 +115,7 @@ function CommandGroup({
<CommandPrimitive.Group <CommandPrimitive.Group
data-slot="command-group" data-slot="command-group"
className={cn( className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-y-auto p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className className
)} )}
{...props} {...props}

View File

@@ -26,8 +26,7 @@ export function UsersSelector({
const [debouncedValue] = useDebounce(userSearchQuery, 150); const [debouncedValue] = useDebounce(userSearchQuery, 150);
// TODO: switch back to 7 items const perPage = 7;
const perPage = 1;
const { data: users = [] } = useQuery( const { data: users = [] } = useQuery(
orgQueries.users({ orgId, perPage, query: debouncedValue }) orgQueries.users({ orgId, perPage, query: debouncedValue })

View File

@@ -152,13 +152,29 @@ export const orgQueries = {
return res.data.data.users; return res.data.data.users;
} }
}), }),
roles: ({ orgId }: { orgId: string }) => roles: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "ROLES"] as const, queryKey: ["ORG", orgId, "ROLES", { query, perPage }] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListRolesResponse> AxiosResponse<ListRolesResponse>
>(`/org/${orgId}/roles`, { signal }); >(`/org/${orgId}/roles?${sp.toString()}`, { signal });
return res.data.data.roles; return res.data.data.roles;
} }