Merge pull request #2893 from Fredkiss3/feat/roles-and-user-multi-selectors

feat: roles & users selector
This commit is contained in:
Owen Schwartz
2026-05-05 17:36:40 -07:00
committed by GitHub
23 changed files with 798 additions and 765 deletions

View File

@@ -0,0 +1,12 @@
services:
mailer:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
volumes:
- mailpit-storage:/data
environment:
- MP_DATABASE=/data/mailpit.db
volumes:
mailpit-storage:

View File

@@ -1356,7 +1356,7 @@
"sidebarSites": "Nœuds", "sidebarSites": "Nœuds",
"sidebarApprovals": "Demandes d'approbation", "sidebarApprovals": "Demandes d'approbation",
"sidebarResources": "Ressource", "sidebarResources": "Ressource",
"sidebarProxyResources": "Publique", "sidebarProxyResources": "Publiques",
"sidebarClientResources": "Privé", "sidebarClientResources": "Privé",
"sidebarAccessControl": "Contrôle d'accès", "sidebarAccessControl": "Contrôle d'accès",
"sidebarLogsAndAnalytics": "Journaux & Analytiques", "sidebarLogsAndAnalytics": "Journaux & Analytiques",
@@ -2458,8 +2458,8 @@
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources", "manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
"downloadClientBannerTitle": "Télécharger le client Pangolin", "downloadClientBannerTitle": "Télécharger le client Pangolin",
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.", "downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
"manageMachineClients": "Gérer les clients de la machine", "manageMachineClients": "Gérer les machines",
"manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources", "manageMachineClientsDescription": "Créer et gérer les clients que les serveurs et systèmes utilisent pour se connecter en privé aux ressources",
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés", "machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.", "machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
"machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerPangolinCLI": "Pangolin CLI",
@@ -3154,6 +3154,7 @@
"healthCheckTabAdvanced": "Avancé", "healthCheckTabAdvanced": "Avancé",
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.", "healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
"uptime30d": "Disponibilité (30j)", "uptime30d": "Disponibilité (30j)",
"uptimeNoData": "Aucune donnée",
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité", "idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
"idpAddActionImportFromOrg": "Importer d'une autre organisation", "idpAddActionImportFromOrg": "Importer d'une autre organisation",
"idpImportDialogTitle": "Importer le fournisseur d'identité", "idpImportDialogTitle": "Importer le fournisseur d'identité",

View File

@@ -175,26 +175,6 @@ export default function GeneralPage() {
}, [variant]); }, [variant]);
useEffect(() => { 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);
}
}
const loadIdp = async ( const loadIdp = async (
availableRoles: { roleId: number; name: string }[] availableRoles: { roleId: number; name: string }[]
) => { ) => {

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

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RolesSelector } from "@app/components/roles-selector";
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
import { import {
@@ -33,6 +34,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { UsersSelector } from "@app/components/users-selector";
import type { ResourceContextType } from "@app/contexts/resourceContext"; import type { ResourceContextType } from "@app/contexts/resourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
@@ -180,13 +182,6 @@ export default function ResourceAuthenticationPage() {
return []; return [];
}, [orgIdps]); }, [orgIdps]);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false); const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
useEffect(() => { useEffect(() => {
@@ -497,46 +492,27 @@ export default function ResourceAuthenticationPage() {
{t("roles")} {t("roles")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<TagInput <RolesSelector
{...field} selectedRoles={
activeTagIndex={ field.value ??
activeRolesTagIndex []
} }
setActiveTagIndex={ restrictAdminRole
setActiveRolesTagIndex orgId={
org.org
.orgId
} }
placeholder={t( onSelectRoles={(
"accessRoleSelect2" newUsers
)}
size="sm"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => { ) => {
usersRolesForm.setValue( usersRolesForm.setValue(
"roles", "roles",
newRoles as [ newUsers as [
Tag, Tag,
...Tag[] ...Tag[]
] ]
); );
}} }}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -557,23 +533,16 @@ export default function ResourceAuthenticationPage() {
{t("users")} {t("users")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<TagInput <UsersSelector
{...field} selectedUsers={
activeTagIndex={ field.value ??
activeUsersTagIndex []
} }
setActiveTagIndex={ orgId={
setActiveUsersTagIndex org.org
.orgId
} }
placeholder={t( onSelectUsers={(
"accessUserSelect"
)}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers newUsers
) => { ) => {
usersRolesForm.setValue( usersRolesForm.setValue(
@@ -584,19 +553,6 @@ export default function ResourceAuthenticationPage() {
] ]
); );
}} }}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

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

@@ -40,7 +40,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ChevronsUpDown, ExternalLink } from "lucide-react"; import {
ArrowDownIcon,
ChevronDownIcon,
ChevronsUpDown,
ExternalLink
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -50,11 +55,13 @@ import {
formatMultiSitesSelectorLabel formatMultiSitesSelectorLabel
} from "./multi-site-selector"; } from "./multi-site-selector";
import type { Selectedsite } from "./site-selector"; import type { Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector"; import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker"; 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 { RolesSelector } from "./roles-selector";
import { build } from "@server/build"; import { build } from "@server/build";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -1118,6 +1125,30 @@ export function InternalResourceForm({
}} }}
/> />
</div> </div>
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
disabled={
httpSectionDisabled
}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<FormField <FormField
control={form.control} control={form.control}
@@ -1484,40 +1515,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 />
@@ -1530,43 +1543,21 @@ 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> <UsersSelector
<TagInput selectedUsers={
{...field} field.value ?? []
activeTagIndex={ }
activeUsersTagIndex orgId={orgId}
} onSelectUsers={(newUsers) => {
setActiveTagIndex={ form.setValue(
setActiveUsersTagIndex "users",
} newUsers as [
placeholder={t( Tag,
"accessUserSelect" ...Tag[]
)} ]
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>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -1580,73 +1571,20 @@ export function InternalResourceForm({
<FormLabel> <FormLabel>
{t("machineClients")} {t("machineClients")}
</FormLabel> </FormLabel>
<Popover> <MachinesSelector
<PopoverTrigger asChild> selectedMachines={
<FormControl> field.value ?? []
<Button }
variant="outline" orgId={orgId}
role="combobox" onSelectMachines={(
className={cn( machines
"justify-between w-full", ) => {
"text-muted-foreground pl-1.5" form.setValue(
)} "clients",
> machines
<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>
<CaretSortIcon 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

@@ -16,6 +16,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import { RolesSelector } from "./roles-selector";
import { useParams } from "next/navigation";
export type RoleMappingRoleOption = { export type RoleMappingRoleOption = {
roleId: number; roleId: number;
@@ -58,9 +60,8 @@ export default function RoleMappingConfigFields({
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null const { orgId } = useParams();
>(null);
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac); const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
const showSingleRoleDisclaimer = const showSingleRoleDisclaimer =
@@ -160,23 +161,16 @@ export default function RoleMappingConfigFields({
{roleMappingMode === "fixedRoles" && ( {roleMappingMode === "fixedRoles" && (
<div className="space-y-2 min-w-0 max-w-full"> <div className="space-y-2 min-w-0 max-w-full">
<TagInput <RolesSelector
tags={fixedRoleNames.map((name) => ({ selectedRoles={fixedRoleNames.map((name) => ({
id: name, id: name,
text: name text: name
}))} }))}
setTags={(nextTags) => { mapRolesByName
const prevTags = fixedRoleNames.map((name) => ({ orgId={orgId as string}
id: name, onSelectRoles={(nextTags) => {
text: name
}));
const next =
typeof nextTags === "function"
? nextTags(prevTags)
: nextTags;
let names = [ let names = [
...new Set(next.map((tag) => tag.text)) ...new Set(nextTags.map((tag) => tag.text))
]; ];
if (!supportsMultipleRolesPerUser) { if (!supportsMultipleRolesPerUser) {
@@ -198,19 +192,6 @@ export default function RoleMappingConfigFields({
onFixedRoleNamesChange(names); onFixedRoleNamesChange(names);
}} }}
activeTagIndex={activeFixedRoleTagIndex}
setActiveTagIndex={setActiveFixedRoleTagIndex}
placeholder={
restrictToOrgRoles
? t("roleMappingFixedRolesPlaceholderSelect")
: t("roleMappingFixedRolesPlaceholderFreeform")
}
enableAutocomplete={restrictToOrgRoles}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={restrictToOrgRoles}
allowDuplicates={false}
sortTags={true}
size="sm"
/> />
<FormDescription> <FormDescription>
{showFreeformRoleNamesHint {showFreeformRoleNamesHint
@@ -352,6 +333,7 @@ function BuilderRuleRow({
}) { }) {
const t = useTranslations(); const t = useTranslations();
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null); const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const { orgId } = useParams();
return ( return (
<div <div
@@ -378,67 +360,109 @@ function BuilderRuleRow({
{t("roleMappingAssignRoles")} {t("roleMappingAssignRoles")}
</FormLabel> </FormLabel>
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
<TagInput {restrictToOrgRoles ? (
tags={rule.roleNames.map((name) => ({ <RolesSelector
id: name, selectedRoles={rule.roleNames.map((name) => ({
text: name
}))}
setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map((name) => ({
id: name, id: name,
text: name text: name
})); }))}
const next = buttonText={t("roleMappingAssignRoles")}
typeof nextTags === "function" mapRolesByName
? nextTags(prevRoleTags) orgId={orgId as string}
: nextTags; onSelectRoles={(nextTags) => {
let names = [
...new Set(nextTags.map((tag) => tag.text))
];
let names = [ if (!supportsMultipleRolesPerUser) {
...new Set(next.map((tag) => tag.text)) if (
]; names.length === 0 &&
rule.roleNames.length > 0
if (!supportsMultipleRolesPerUser) { ) {
if ( onChange({
names.length === 0 && ...rule,
rule.roleNames.length > 0 roleNames: [
) { rule.roleNames[
onChange({ rule.roleNames.length - 1
...rule, ]!
roleNames: [ ]
rule.roleNames[ });
rule.roleNames.length - 1 return;
]! }
] if (names.length > 1) {
}); names = [names[names.length - 1]!];
return; }
} }
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onChange({ onChange({
...rule, ...rule,
roleNames: names roleNames: names
}); });
}} }}
activeTagIndex={activeTagIndex} />
setActiveTagIndex={setActiveTagIndex} ) : (
placeholder={ <TagInput
restrictToOrgRoles tags={rule.roleNames.map((name) => ({
? t("roleMappingAssignRoles") id: name,
: t("roleMappingAssignRolesPlaceholderFreeform") text: name
} }))}
enableAutocomplete={restrictToOrgRoles} setTags={(nextTags) => {
autocompleteOptions={roleOptions} const prevRoleTags = rule.roleNames.map(
restrictTagsToAutocompleteOptions={restrictToOrgRoles} (name) => ({
allowDuplicates={false} id: name,
sortTags={true} text: name
size="sm" })
styleClasses={{ );
inlineTagsContainer: "min-w-0 max-w-full" const next =
}} typeof nextTags === "function"
/> ? nextTags(prevRoleTags)
: nextTags;
let names = [
...new Set(next.map((tag) => tag.text))
];
if (!supportsMultipleRolesPerUser) {
if (
names.length === 0 &&
rule.roleNames.length > 0
) {
onChange({
...rule,
roleNames: [
rule.roleNames[
rule.roleNames.length - 1
]!
]
});
return;
}
if (names.length > 1) {
names = [names[names.length - 1]!];
}
}
onChange({
...rule,
roleNames: names
});
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder={t(
"roleMappingAssignRolesPlaceholderFreeform"
)}
enableAutocomplete={false}
autocompleteOptions={roleOptions}
restrictTagsToAutocompleteOptions={false}
allowDuplicates={false}
sortTags={true}
size="sm"
styleClasses={{
inlineTagsContainer: "min-w-0 max-w-full"
}}
/>
)}
</div> </div>
{showFreeformRoleNamesHint && ( {showFreeformRoleNamesHint && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -1,18 +1,5 @@
"use client"; "use client";
import { useState, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { BellPlus, BellRing } from "lucide-react";
import {
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody
} from "@app/components/Settings";
import UptimeBar from "@app/components/UptimeBar";
import { Button } from "@app/components/ui/button";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@@ -23,18 +10,32 @@ import {
CredenzaHeader, CredenzaHeader,
CredenzaTitle CredenzaTitle
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import UptimeBar from "@app/components/UptimeBar";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label"; import { Label } from "@app/components/ui/label";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { orgQueries } from "@app/lib/queries";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BellPlus, BellRing } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
import { RolesSelector } from "./roles-selector";
import { UsersSelector } from "./users-selector";
interface UptimeAlertSectionProps { interface UptimeAlertSectionProps {
orgId: string; orgId: string;
@@ -64,12 +65,7 @@ export default function UptimeAlertSection({
const [userTags, setUserTags] = useState<Tag[]>([]); const [userTags, setUserTags] = useState<Tag[]>([]);
const [roleTags, setRoleTags] = useState<Tag[]>([]); const [roleTags, setRoleTags] = useState<Tag[]>([]);
const [emailTags, setEmailTags] = useState<Tag[]>([]); const [emailTags, setEmailTags] = useState<Tag[]>([]);
const [activeUserTagIndex, setActiveUserTagIndex] = useState<number | null>(
null
);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
number | null number | null
>(null); >(null);
@@ -80,27 +76,6 @@ export default function UptimeAlertSection({
enabled: isPaid enabled: isPaid
}); });
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
const allUsers = useMemo(
() =>
orgUsers.map((u) => ({
id: String(u.id),
text: getUserDisplayName({
email: u.email,
name: u.name,
username: u.username
})
})),
[orgUsers]
);
const allRoles = useMemo(
() => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })),
[orgRoles]
);
const hasRules = (alertRules?.length ?? 0) > 0; const hasRules = (alertRules?.length ?? 0) > 0;
async function handleSubmit() { async function handleSubmit() {
@@ -227,10 +202,16 @@ export default function UptimeAlertSection({
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-4"> <div className="space-y-4">
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} /> <PaidFeaturesAlert
tiers={tierMatrix.alertingRules}
/>
<fieldset <fieldset
disabled={!isPaid} disabled={!isPaid}
className={!isPaid ? "opacity-50 pointer-events-none" : ""} className={
!isPaid
? "opacity-50 pointer-events-none"
: ""
}
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -240,65 +221,53 @@ export default function UptimeAlertSection({
<Input <Input
id="alert-name" id="alert-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) =>
placeholder={t("uptimeAlertNamePlaceholder")} setName(e.target.value)
}
placeholder={t(
"uptimeAlertNamePlaceholder"
)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("alertingNotifyUsers")}</Label> <Label>
<TagInput {t("alertingNotifyUsers")}
activeTagIndex={activeUserTagIndex} </Label>
setActiveTagIndex={setActiveUserTagIndex} <UsersSelector
placeholder={t("alertingSelectUsers")} selectedUsers={userTags}
size="sm" orgId={orgId}
tags={userTags} onSelectUsers={setUserTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
setUserTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allUsers}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("alertingNotifyRoles")}</Label> <Label>
<TagInput {t("alertingNotifyRoles")}
activeTagIndex={activeRoleTagIndex} </Label>
setActiveTagIndex={setActiveRoleTagIndex} <RolesSelector
placeholder={t("alertingSelectRoles")} selectedRoles={roleTags}
size="sm" restrictAdminRole
tags={roleTags} orgId={orgId}
setTags={(newTags) => { onSelectRoles={setRoleTags}
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
setRoleTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allRoles}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("uptimeAdditionalEmails")}</Label> <Label>
{t("uptimeAdditionalEmails")}
</Label>
<TagInput <TagInput
activeTagIndex={activeEmailTagIndex} activeTagIndex={activeEmailTagIndex}
setActiveTagIndex={setActiveEmailTagIndex} setActiveTagIndex={
placeholder={t("alertingEmailPlaceholder")} setActiveEmailTagIndex
}
placeholder={t(
"alertingEmailPlaceholder"
)}
size="sm" size="sm"
tags={emailTags} tags={emailTags}
setTags={(newTags) => { setTags={(newTags) => {
const next = const next =
typeof newTags === "function" typeof newTags ===
"function"
? newTags(emailTags) ? newTags(emailTags)
: newTags; : newTags;
setEmailTags(next as Tag[]); setEmailTags(next as Tag[]);
@@ -306,7 +275,9 @@ export default function UptimeAlertSection({
allowDuplicates={false} allowDuplicates={false}
sortTags sortTags
validateTag={(tag) => validateTag={(tag) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
tag
)
} }
delimiterList={[",", "Enter"]} delimiterList={[",", "Enter"]}
/> />

View File

@@ -1,5 +1,8 @@
"use client"; "use client";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { StrategySelect } from "@app/components/StrategySelect";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { import {
@@ -21,11 +24,13 @@ import {
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { Textarea } from "@app/components/ui/textarea"; import { Textarea } from "@app/components/ui/textarea";
import { Label } from "@app/components/ui/label";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -33,24 +38,21 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { StrategySelect } from "@app/components/StrategySelect";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { import {
type AlertRuleFormAction, type AlertRuleFormAction,
type AlertRuleFormValues type AlertRuleFormValues
} from "@app/lib/alertRuleForm"; } from "@app/lib/alertRuleForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { Control, UseFormReturn } from "react-hook-form"; import type { Control, UseFormReturn } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { RolesSelector } from "../roles-selector";
import { UsersSelector } from "../users-selector";
export function AddActionPanel({ export function AddActionPanel({
onAdd onAdd
@@ -498,12 +500,6 @@ function NotifyActionFields({
const t = useTranslations(); const t = useTranslations();
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null); const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery( const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(
orgQueries.users({ orgId }) orgQueries.users({ orgId })
@@ -574,14 +570,6 @@ function NotifyActionFields({
hasResolvedTagsRef.current = true; hasResolvedTagsRef.current = true;
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]); }, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
const userTags = (useWatch({
control,
name: `actions.${index}.userTags`
}) ?? []) as Tag[];
const roleTags = (useWatch({
control,
name: `actions.${index}.roleTags`
}) ?? []) as Tag[];
const emailTags = (useWatch({ const emailTags = (useWatch({
control, control,
name: `actions.${index}.emailTags` name: `actions.${index}.emailTags`
@@ -596,29 +584,16 @@ function NotifyActionFields({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyUsers")}</FormLabel> <FormLabel>{t("alertingNotifyUsers")}</FormLabel>
<FormControl> <FormControl>
<TagInput <UsersSelector
{...field} selectedUsers={field.value ?? []}
activeTagIndex={activeUsersTagIndex} orgId={orgId}
setActiveTagIndex={setActiveUsersTagIndex} onSelectUsers={(newUsers) => {
placeholder={t("alertingSelectUsers")}
size="sm"
tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
form.setValue( form.setValue(
`actions.${index}.userTags`, `actions.${index}.userTags`,
next as Tag[], newUsers as [Tag, ...Tag[]],
{ shouldDirty: true } { shouldDirty: true }
); );
}} }}
enableAutocomplete={true}
autocompleteOptions={allUsers}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -632,29 +607,17 @@ function NotifyActionFields({
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyRoles")}</FormLabel> <FormLabel>{t("alertingNotifyRoles")}</FormLabel>
<FormControl> <FormControl>
<TagInput <RolesSelector
{...field} selectedRoles={field.value ?? []}
activeTagIndex={activeRolesTagIndex} restrictAdminRole
setActiveTagIndex={setActiveRolesTagIndex} orgId={orgId}
placeholder={t("alertingSelectRoles")} onSelectRoles={(newUsers) => {
size="sm"
tags={roleTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
form.setValue( form.setValue(
`actions.${index}.roleTags`, `actions.${index}.roleTags`,
next as Tag[], newUsers as [Tag, ...Tag[]],
{ shouldDirty: true } { shouldDirty: true }
); );
}} }}
enableAutocomplete={true}
autocompleteOptions={allRoles}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -5,7 +5,7 @@ import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { MultiSelectTags } from "./multi-select-tags"; import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedMachine = Pick< export type SelectedMachine = Pick<
ListClientsResponse["clients"][number], ListClientsResponse["clients"][number],
@@ -28,11 +28,13 @@ export function MachinesSelector({
const [debouncedValue] = useDebounce(machineSearchQuery, 150); const [debouncedValue] = useDebounce(machineSearchQuery, 150);
const perPage = 7;
const { data: machines = [] } = useQuery( const { data: machines = [] } = useQuery(
orgQueries.machineClients({ orgId, perPage: 10, query: debouncedValue }) orgQueries.machineClients({ orgId, perPage, query: debouncedValue })
); );
// always include the selected machines in the list of machines shown (if the user isn't searching) // always include the selected machines in the list (if the user isn't searching)
const machinesShown = useMemo(() => { const machinesShown = useMemo(() => {
const allMachines: Array<SelectedMachine> = [...machines]; const allMachines: Array<SelectedMachine> = [...machines];
if (debouncedValue.trim().length === 0) { if (debouncedValue.trim().length === 0) {
@@ -44,75 +46,32 @@ export function MachinesSelector({
} }
} }
} }
return allMachines; return allMachines;
}, [machines, selectedMachines, debouncedValue]); }, [machines, selectedMachines, debouncedValue]);
// const selectedMachinesIds = new Set(
// selectedMachines.map((m) => m.clientId)
// );
return ( return (
<MultiSelectTags <MultiSelectTagInput
buttonText={t("accessClientSelect")}
searchPlaceholder={t("search")}
emptyPlaceholder={t("machineNotFound")} emptyPlaceholder={t("machineNotFound")}
searchPlaceholder={t("machineSearch")}
value={selectedMachines.map((m) => ({
...m,
text: m.name,
id: m.clientId.toString()
}))}
onChange={(values) => {
onSelectMachines(values);
}}
options={machinesShown.map((m) => ({
...m,
id: m.clientId.toString(),
text: m.name
}))}
onSearch={setMachineSearchQuery}
searchQuery={machineSearchQuery} searchQuery={machineSearchQuery}
onSearch={setMachineSearchQuery}
options={machinesShown.map((mc) => ({
id: mc.clientId.toString(),
text: mc.name
}))}
value={selectedMachines.map((mc) => ({
id: mc.clientId.toString(),
text: mc.name
}))}
onChange={(newValues) => {
onSelectMachines(
newValues.map((v) => ({
clientId: Number(v.id),
name: v.text
}))
);
}}
/> />
// <Command shouldFilter={false}>
// <CommandInput
// placeholder={t("machineSearch")}
// value={machineSearchQuery}
// onValueChange={setMachineSearchQuery}
// />
// <CommandList>
// <CommandEmpty>{t("machineNotFound")}</CommandEmpty>
// <CommandGroup>
// {machinesShown.map((m) => (
// <CommandItem
// value={`${m.name}:${m.clientId}`}
// key={m.clientId}
// onSelect={() => {
// let newMachineClients = [];
// if (selectedMachinesIds.has(m.clientId)) {
// newMachineClients = selectedMachines.filter(
// (mc) => mc.clientId !== m.clientId
// );
// } else {
// newMachineClients = [
// ...selectedMachines,
// m
// ];
// }
// onSelectMachines(newMachineClients);
// }}
// >
// <CheckIcon
// className={cn(
// "mr-2 h-4 w-4",
// selectedMachinesIds.has(m.clientId)
// ? "opacity-100"
// : "opacity-0"
// )}
// />
// {`${m.name}`}
// </CommandItem>
// ))}
// </CommandGroup>
// </CommandList>
// </Command>
); );
} }

View File

@@ -6,24 +6,26 @@ import {
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList CommandList
} from "./ui/command"; } from "../ui/command";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export type TagValue = { text: string; id: string }; export type TagValue = { text: string; id: string };
export type MultiSelectTagsProps<T extends TagValue> = { export type MultiSelectTagsProps<T extends TagValue> = {
emptyPlaceholder: string; emptyPlaceholder?: string;
searchPlaceholder: string; searchPlaceholder?: string;
searchQuery?: string; searchQuery?: string;
options: Array<T>; options: Array<T>;
value: Array<T>; value: Array<T>;
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 MultiSelectTags<T extends TagValue>({ export function MultiSelectContent<T extends TagValue>({
emptyPlaceholder, emptyPlaceholder,
searchPlaceholder, searchPlaceholder,
searchQuery, searchQuery,
@@ -32,16 +34,19 @@ export function MultiSelectTags<T extends TagValue>({
onSearch, onSearch,
onChange onChange
}: MultiSelectTagsProps<T>) { }: MultiSelectTagsProps<T>) {
const t = useTranslations();
const selectedValues = new Set(value.map((v) => v.id)); const selectedValues = new Set(value.map((v) => v.id));
return ( return (
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder={searchPlaceholder} placeholder={searchPlaceholder ?? t("search")}
value={searchQuery} value={searchQuery}
onValueChange={onSearch} onValueChange={onSearch}
/> />
<CommandList> <CommandList>
<CommandEmpty>{emptyPlaceholder}</CommandEmpty> <CommandEmpty className="text-muted-foreground">
{emptyPlaceholder ?? t("noResults")}
</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => ( {options.map((option) => (
<CommandItem <CommandItem

View File

@@ -0,0 +1,98 @@
import { buttonVariants } from "@app/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { ChevronDownIcon, XIcon } from "lucide-react";
import {
type MultiSelectTagsProps,
type TagValue,
MultiSelectContent
} from "./multi-select-content";
export interface MultiSelectInputProps<
T extends TagValue
> extends MultiSelectTagsProps<T> {
buttonText?: string;
}
export function MultiSelectTagInput<T extends TagValue>({
buttonText,
...props
}: MultiSelectInputProps<T>) {
const selectedValues = new Set(props.value.map((v) => v.id));
return (
<Popover
onOpenChange={(open) => {
if (!open) {
// clear input when popover is closed
props.onSearch("");
}
}}
>
<PopoverTrigger asChild>
<div
role="combobox"
className={cn(
buttonVariants({
variant: "outline"
}),
"justify-between w-full inline-flex",
"text-muted-foreground pl-1.5 cursor-text",
"hover:bg-transparent hover:text-muted-foreground",
props.disabled && "pointer-events-none opacity-50"
)}
>
<span
className={cn(
"inline-flex items-center gap-1",
"overflow-x-auto"
)}
>
{props.value.map((option) => (
<span
key={option.id}
className={cn(
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
)}
onClick={(e) => e.stopPropagation()}
>
{option.text}
<button
className="p-0.5 flex-none cursor-pointer"
type="button"
onClick={(e) => {
e.stopPropagation();
let newValues = [];
if (selectedValues.has(option.id)) {
newValues = props.value.filter(
(v) => v.id !== option.id
);
} else {
newValues = [
...props.value,
option
];
}
props.onChange(newValues);
}}
>
<XIcon className="size-3.5" />
</button>
</span>
))}
<span className="pl-1 font-normal">{buttonText}</span>
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</div>
</PopoverTrigger>
<PopoverContent className="p-0">
<MultiSelectContent {...props} />
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,81 @@
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;
restrictAdminRole?: boolean;
mapRolesByName?: boolean;
buttonText?: string;
};
export function RolesSelector({
orgId,
selectedRoles = [],
onSelectRoles,
disabled,
restrictAdminRole,
mapRolesByName,
buttonText
}: RolesSelectorProps) {
const t = useTranslations();
const [roleSearchQuery, setRoleSearchQuery] = useState("");
const [debouncedValue] = useDebounce(roleSearchQuery, 150);
const { data: roles = [] } = useQuery(
orgQueries.roles({ orgId, perPage: 10, query: debouncedValue })
);
// always include the selected roles in the list (if the user isn't searching)
const rolesShown = useMemo(() => {
let allRoles: Array<SelectedRole & { isAdmin?: boolean }> = roles.map(
(r) => ({
id: mapRolesByName ? r.name : r.roleId.toString(),
text: r.name,
isAdmin: Boolean(r.isAdmin)
})
);
if (debouncedValue.trim().length === 0) {
for (const role of selectedRoles) {
if (!allRoles.find((r) => r.id === role.id)) {
allRoles.unshift(role);
}
}
}
if (restrictAdminRole) {
allRoles = allRoles.filter((role) => !role.isAdmin);
}
return allRoles;
}, [
roles,
selectedRoles,
debouncedValue,
restrictAdminRole,
mapRolesByName
]);
return (
<MultiSelectTagInput
buttonText={buttonText ?? t("alertingSelectRoles")}
searchQuery={roleSearchQuery}
onSearch={setRoleSearchQuery}
options={rolesShown}
value={selectedRoles}
onChange={onSelectRoles}
disabled={disabled}
/>
);
}

View File

@@ -1,4 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input";
import { import {
Command, Command,
@@ -220,7 +226,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
> >
<PopoverAnchor asChild> <PopoverAnchor asChild>
<div <div
className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-3" className="relative h-full flex items-center rounded-md border border-input bg-transparent pr-1"
ref={triggerContainerRef} ref={triggerContainerRef}
> >
{childrenWithProps} {childrenWithProps}
@@ -260,10 +266,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
side="bottom" side="bottom"
align="start" align="start"
forceMount forceMount
className={cn( className={cn("p-0", classStyleProps?.popoverContent)}
"p-0",
classStyleProps?.popoverContent
)}
style={{ style={{
width: `${popoverWidth}px`, width: `${popoverWidth}px`,
minWidth: `${popoverWidth}px`, minWidth: `${popoverWidth}px`,
@@ -300,7 +303,9 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
key={option.id} key={option.id}
value={`${option.text} ${option.id}`} value={`${option.text} ${option.id}`}
onSelect={() => toggleTag(option)} onSelect={() => toggleTag(option)}
className={classStyleProps?.commandItem} className={
classStyleProps?.commandItem
}
> >
<Check <Check
className={cn( className={cn(

View File

@@ -85,6 +85,8 @@ export interface TagInputProps
autocompleteFilter?: (option: string) => boolean; autocompleteFilter?: (option: string) => boolean;
direction?: "row" | "column"; direction?: "row" | "column";
onInputChange?: (value: string) => void; onInputChange?: (value: string) => void;
searchQuery?: string;
onSearchQueryChange?: (value: string) => void;
customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode;
onFocus?: React.FocusEventHandler<HTMLInputElement>; onFocus?: React.FocusEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>; onBlur?: React.FocusEventHandler<HTMLInputElement>;
@@ -157,10 +159,24 @@ export function TagInput({ ref, ...props }: TagInputProps) {
disabled = false, disabled = false,
usePortal = false, usePortal = false,
addOnPaste = false, addOnPaste = false,
generateTagId = uuid generateTagId = uuid,
searchQuery,
onSearchQueryChange
} = props; } = props;
const [inputValue, setInputValue] = React.useState(""); const [inputValue, setInputValue] = React.useState("");
const isControlled = searchQuery !== undefined;
const effectiveQuery = isControlled ? searchQuery : inputValue;
const updateQuery = React.useCallback(
(action: React.SetStateAction<string>) => {
const resolved =
typeof action === "function" ? action(effectiveQuery) : action;
if (!isControlled) setInputValue(resolved);
onSearchQueryChange?.(resolved);
},
[isControlled, effectiveQuery, onSearchQueryChange]
);
const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length)); const [tagCount, setTagCount] = React.useState(Math.max(0, tags.length));
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
@@ -234,9 +250,9 @@ export function TagInput({ ref, ...props }: TagInputProps) {
); );
} }
}); });
setInputValue(""); updateQuery("");
} else { } else {
setInputValue(newValue); updateQuery(newValue);
} }
onInputChange?.(newValue); onInputChange?.(newValue);
}; };
@@ -247,8 +263,8 @@ export function TagInput({ ref, ...props }: TagInputProps) {
}; };
const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => { const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
if (addTagsOnBlur && inputValue.trim()) { if (addTagsOnBlur && effectiveQuery.trim()) {
const newTagText = inputValue.trim(); const newTagText = effectiveQuery.trim();
if (validateTag && !validateTag(newTagText)) { if (validateTag && !validateTag(newTagText)) {
return; return;
@@ -273,7 +289,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
setTags([...tags, { id: newTagId, text: newTagText }]); setTags([...tags, { id: newTagId, text: newTagText }]);
onTagAdd?.(newTagText); onTagAdd?.(newTagText);
setTagCount((prevTagCount) => prevTagCount + 1); setTagCount((prevTagCount) => prevTagCount + 1);
setInputValue(""); updateQuery("");
} }
} }
@@ -287,7 +303,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
: e.key === delimiter || e.key === Delimiter.Enter : e.key === delimiter || e.key === Delimiter.Enter
) { ) {
e.preventDefault(); e.preventDefault();
const newTagText = inputValue.trim(); const newTagText = effectiveQuery.trim();
// Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true
if ( if (
@@ -329,7 +345,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
onTagAdd?.(newTagText); onTagAdd?.(newTagText);
setTagCount((prevTagCount) => prevTagCount + 1); setTagCount((prevTagCount) => prevTagCount + 1);
} }
setInputValue(""); updateQuery("");
} else { } else {
switch (e.key) { switch (e.key) {
case "Delete": case "Delete":
@@ -419,9 +435,6 @@ export function TagInput({ ref, ...props }: TagInputProps) {
onClearAll?.(); onClearAll?.();
}; };
// const filteredAutocompleteOptions = autocompleteFilter
// ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text))
// : autocompleteOptions;
const displayedTags = sortTags ? [...tags].sort() : tags; const displayedTags = sortTags ? [...tags].sort() : tags;
const truncatedTags = truncate const truncatedTags = truncate
@@ -436,13 +449,15 @@ export function TagInput({ ref, ...props }: TagInputProps) {
return ( return (
<div <div
className={`w-full flex ${!inlineTags && tags.length > 0 ? "gap-3" : ""} ${ className={cn(
`w-full flex`,
!inlineTags && tags.length > 0 && "gap-3",
inputFieldPosition === "bottom" inputFieldPosition === "bottom"
? "flex-col" ? "flex-col"
: inputFieldPosition === "top" : inputFieldPosition === "top"
? "flex-col-reverse" ? "flex-col-reverse"
: "flex-row" : "flex-row"
}`} )}
> >
{!usePopoverForTags && {!usePopoverForTags &&
(!inlineTags ? ( (!inlineTags ? (
@@ -515,14 +530,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={inputValue} value={effectiveQuery}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", "border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@@ -544,16 +559,17 @@ export function TagInput({ ref, ...props }: TagInputProps) {
</div> </div>
) )
))} ))}
{enableAutocomplete ? ( {enableAutocomplete ? (
<div className="w-full"> <div className="w-full">
<Autocomplete <Autocomplete
tags={tags} tags={tags}
setTags={setTags} setTags={setTags}
setInputValue={setInputValue} setInputValue={updateQuery}
autocompleteOptions={ autocompleteOptions={
(autocompleteOptions || []) as Tag[] (autocompleteOptions || []) as Tag[]
} }
filterQuery={inputValue} filterQuery={effectiveQuery}
setTagCount={setTagCount} setTagCount={setTagCount}
maxTags={maxTags} maxTags={maxTags}
onTagAdd={onTagAdd} onTagAdd={onTagAdd}
@@ -579,7 +595,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
// <CommandInput // <CommandInput
// placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder} // placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
// ref={inputRef} // ref={inputRef}
// value={inputValue} // value={effectiveQuery}
// disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
// onChangeCapture={handleInputChange} // onChangeCapture={handleInputChange}
// onKeyDown={handleKeyDown} // onKeyDown={handleKeyDown}
@@ -601,14 +617,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={inputValue} value={effectiveQuery}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", "border-0 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@@ -662,7 +678,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
{/* <CommandInput {/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder} placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
ref={inputRef} ref={inputRef}
value={inputValue} value={effectiveQuery}
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
onChangeCapture={handleInputChange} onChangeCapture={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -685,14 +701,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={inputValue} value={effectiveQuery}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", "border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@@ -741,7 +757,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
{/* <CommandInput {/* <CommandInput
placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder} placeholder={maxTags !== undefined && tags.length >= maxTags ? placeholderWhenFull : placeholder}
ref={inputRef} ref={inputRef}
value={inputValue} value={effectiveQuery}
disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)}
onChangeCapture={handleInputChange} onChangeCapture={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -763,14 +779,14 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={inputValue} value={effectiveQuery}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}
{...inputProps} {...inputProps}
className={cn( className={cn(
"border-0 px-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", "border-0 px-2 h-6 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className, // className,
styleClasses?.input styleClasses?.input
)} )}
@@ -806,7 +822,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={inputValue} value={effectiveQuery}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}
@@ -866,7 +882,7 @@ export function TagInput({ ref, ...props }: TagInputProps) {
? placeholderWhenFull ? placeholderWhenFull
: placeholder : placeholder
} }
value={inputValue} value={effectiveQuery}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleInputFocus} onFocus={handleInputFocus}

View File

@@ -87,7 +87,7 @@ function CommandList({
<CommandPrimitive.List <CommandPrimitive.List
data-slot="command-list" data-slot="command-list"
className={cn( className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", "max-h-[300px] scroll-py-1 overflow-x-clip overflow-y-auto",
className className
)} )}
{...props} {...props}
@@ -96,12 +96,13 @@ function CommandList({
} }
function CommandEmpty({ function CommandEmpty({
className,
...props ...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) { }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return ( return (
<CommandPrimitive.Empty <CommandPrimitive.Empty
data-slot="command-empty" data-slot="command-empty"
className="py-6 text-center text-sm" className={cn("py-6 text-center text-sm", className)}
{...props} {...props}
/> />
); );
@@ -115,7 +116,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

@@ -566,7 +566,7 @@ export function ControlledDataTable<TData, TValue>({
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {(table.getRowModel().rows ?? []).length > 0 ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}

View File

@@ -0,0 +1,63 @@
import { orgQueries } from "@app/lib/queries";
import type { ListUsersResponse } from "@server/routers/user";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
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 SelectedUser = {
id: string;
text: string;
ipdName?: string | null;
};
export type UsersSelectorProps = {
orgId: string;
selectedUsers?: SelectedUser[];
onSelectUsers: (users: SelectedUser[]) => void;
};
export function UsersSelector({
orgId,
selectedUsers = [],
onSelectUsers
}: UsersSelectorProps) {
const t = useTranslations();
const [userSearchQuery, setUserSearchQuery] = useState("");
const [debouncedValue] = useDebounce(userSearchQuery, 150);
const { data: users = [] } = useQuery(
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
);
// always include the selected users in the list (if the user isn't searching)
const usersShown = useMemo(() => {
const allUsers: Array<SelectedUser> = users.map((u) => ({
id: u.id,
text: getUserDisplayName(u)
}));
if (debouncedValue.trim().length === 0) {
for (const user of selectedUsers) {
if (!allUsers.find((u) => u.id === user.id)) {
allUsers.unshift(user);
}
}
}
return allUsers;
}, [users, selectedUsers, debouncedValue]);
return (
<MultiSelectTagInput
buttonText={t("alertingSelectUsers")}
searchQuery={userSearchQuery}
onSearch={setUserSearchQuery}
options={usersShown}
value={selectedUsers}
onChange={onSelectUsers}
/>
);
}

View File

@@ -8,6 +8,7 @@ type UserDisplayNameInput =
email?: string | null; email?: string | null;
name?: string | null; name?: string | null;
username?: string | null; username?: string | null;
idpName?: string | null;
}; };
/** /**
@@ -21,16 +22,25 @@ export function getUserDisplayName(input: UserDisplayNameInput): string {
let email: string | null | undefined; let email: string | null | undefined;
let name: string | null | undefined; let name: string | null | undefined;
let username: string | null | undefined; let username: string | null | undefined;
let idpName: string | null | undefined;
if ("user" in input) { if ("user" in input) {
email = input.user.email; email = input.user.email;
name = input.user.name; name = input.user.name;
username = input.user.username; username = input.user.username;
idpName = input.user.idpName;
} else { } else {
email = input.email; email = input.email;
name = input.name; name = input.name;
username = input.username; username = input.username;
idpName = input.idpName;
} }
return email || name || username || ""; let nameShown = email || name || username || "";
if (idpName) {
nameShown = `${nameShown} (${idpName})`;
}
return nameShown;
} }

View File

@@ -125,24 +125,56 @@ export const orgQueries = {
return res.data.data.clients; return res.data.data.clients;
} }
}), }),
users: ({ orgId }: { orgId: string }) => users: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "USERS"] as const, queryKey: ["ORG", orgId, "USERS", { 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<ListUsersResponse> AxiosResponse<ListUsersResponse>
>(`/org/${orgId}/users`, { signal }); >(`/org/${orgId}/users?${sp.toString()}`, { signal });
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;
} }