diff --git a/docker-compose.mailpit.yml b/docker-compose.mailpit.yml new file mode 100644 index 000000000..b801ec735 --- /dev/null +++ b/docker-compose.mailpit.yml @@ -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: diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 58eb7d628..b492a71ef 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1356,7 +1356,7 @@ "sidebarSites": "Nœuds", "sidebarApprovals": "Demandes d'approbation", "sidebarResources": "Ressource", - "sidebarProxyResources": "Publique", + "sidebarProxyResources": "Publiques", "sidebarClientResources": "Privé", "sidebarAccessControl": "Contrôle d'accès", "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", "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.", - "manageMachineClients": "Gérer les clients de la machine", - "manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources", + "manageMachineClients": "Gérer les machines", + "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", "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", @@ -3154,6 +3154,7 @@ "healthCheckTabAdvanced": "Avancé", "healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.", "uptime30d": "Disponibilité (30j)", + "uptimeNoData": "Aucune donnée", "idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité", "idpAddActionImportFromOrg": "Importer d'une autre organisation", "idpImportDialogTitle": "Importer le fournisseur d'identité", diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 90b89f76f..69d57345c 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -175,26 +175,6 @@ export default function GeneralPage() { }, [variant]); useEffect(() => { - async function fetchRoles() { - const res = await api - .get>(`/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 ( availableRoles: { roleId: number; name: string }[] ) => { diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 9ab9e93fa..717d7f211 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -1,44 +1,40 @@ "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 { Form, FormControl, FormField, FormItem, - FormLabel, - FormMessage + FormLabel } from "@app/components/ui/form"; -import { Checkbox } from "@app/components/ui/checkbox"; -import OrgRolesTagField from "@app/components/OrgRolesTagField"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { AxiosResponse } from "axios"; -import { useEffect, useState } from "react"; +import { build } from "@server/build"; +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 { 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({ username: z.string(), @@ -59,12 +55,6 @@ export default function AccessControlsPage() { const { orgId } = useParams(); - const [loading, setLoading] = useState(false); - const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( - null - ); - const t = useTranslations(); const { isPaidUser } = usePaidStatus(); const isPaid = isPaidUser(tierMatrix.fullRbac); @@ -97,44 +87,21 @@ export default function AccessControlsPage() { text: r.name })) ); - }, [user.userId, currentRoleIds.join(",")]); - - useEffect(() => { - async function fetchRoles() { - const res = await api - .get>(`/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); - }, []); - - const allRoleOptions = roles.map((role) => ({ - id: role.roleId.toString(), - text: role.name - })); + }, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]); const paywallMessage = build === "saas" ? t("singleRolePerUserPlanNotice") : t("singleRolePerUserEditionNotice"); - async function onSubmit(values: z.infer) { + 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) { toast({ variant: "destructive", @@ -144,7 +111,6 @@ export default function AccessControlsPage() { return; } - setLoading(true); try { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const updateRoleRequest = supportsMultipleRolesPerUser @@ -184,7 +150,6 @@ export default function AccessControlsPage() { ) }); } - setLoading(false); } return ( @@ -203,7 +168,7 @@ export default function AccessControlsPage() {
@@ -226,9 +191,7 @@ export default function AccessControlsPage() { {user.idpAutoProvision && ( @@ -277,8 +237,8 @@ export default function AccessControlsPage() { - - - - { - form.setValue( - "clients", - machines - ); - }} - /> - - + { + form.setValue( + "clients", + machines + ); + }} + /> )} diff --git a/src/components/OrgRolesTagField.tsx b/src/components/OrgRolesTagField.tsx index dcd679663..bc8e5a0b5 100644 --- a/src/components/OrgRolesTagField.tsx +++ b/src/components/OrgRolesTagField.tsx @@ -8,51 +8,42 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; + import { toast } from "@app/hooks/useToast"; import { useTranslations } from "next-intl"; -import type { Dispatch, SetStateAction } from "react"; -import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; -export type RoleTag = { - id: string; - text: string; -}; +import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; +import { RolesSelector, type SelectedRole } from "./roles-selector"; type OrgRolesTagFieldProps = { - form: Pick, "control" | "getValues" | "setValue">; + form: Pick< + UseFormReturn, + "control" | "getValues" | "setValue" + >; + orgId: string; /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ name?: Path; - label: string; - placeholder: string; - allRoleOptions: Tag[]; + label?: string; supportsMultipleRolesPerUser: boolean; showMultiRolePaywallMessage: boolean; paywallMessage: string; - loading?: boolean; - activeTagIndex: number | null; - setActiveTagIndex: Dispatch>; + disabled?: boolean; }; export default function OrgRolesTagField({ form, name = "roles" as Path, label, - placeholder, - allRoleOptions, + orgId, supportsMultipleRolesPerUser, showMultiRolePaywallMessage, paywallMessage, - loading = false, - activeTagIndex, - setActiveTagIndex + disabled }: OrgRolesTagFieldProps) { const t = useTranslations(); - function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) { - const prev = form.getValues(name) as Tag[]; - const nextValue = - typeof updater === "function" ? updater(prev) : updater; + function setRoleTags(nextValue: SelectedRole[]) { + const prev = form.getValues(name) as SelectedRole[]; const next = supportsMultipleRolesPerUser ? nextValue : nextValue.length > 1 @@ -88,22 +79,13 @@ export default function OrgRolesTagField({ name={name} render={({ field }) => ( - {label} + {label ?? t("roles")} - {showMultiRolePaywallMessage && ( diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx index d62b7f9e8..906f85f62 100644 --- a/src/components/RoleMappingConfigFields.tsx +++ b/src/components/RoleMappingConfigFields.tsx @@ -16,6 +16,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; +import { RolesSelector } from "./roles-selector"; +import { useParams } from "next/navigation"; export type RoleMappingRoleOption = { roleId: number; @@ -58,9 +60,8 @@ export default function RoleMappingConfigFields({ const t = useTranslations(); const { env } = useEnvContext(); const { isPaidUser } = usePaidStatus(); - const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState< - number | null - >(null); + + const { orgId } = useParams(); const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac); const showSingleRoleDisclaimer = @@ -160,23 +161,16 @@ export default function RoleMappingConfigFields({ {roleMappingMode === "fixedRoles" && (
- ({ + ({ id: name, text: name }))} - setTags={(nextTags) => { - const prevTags = fixedRoleNames.map((name) => ({ - id: name, - text: name - })); - const next = - typeof nextTags === "function" - ? nextTags(prevTags) - : nextTags; - + mapRolesByName + orgId={orgId as string} + onSelectRoles={(nextTags) => { let names = [ - ...new Set(next.map((tag) => tag.text)) + ...new Set(nextTags.map((tag) => tag.text)) ]; if (!supportsMultipleRolesPerUser) { @@ -198,19 +192,6 @@ export default function RoleMappingConfigFields({ onFixedRoleNamesChange(names); }} - activeTagIndex={activeFixedRoleTagIndex} - setActiveTagIndex={setActiveFixedRoleTagIndex} - placeholder={ - restrictToOrgRoles - ? t("roleMappingFixedRolesPlaceholderSelect") - : t("roleMappingFixedRolesPlaceholderFreeform") - } - enableAutocomplete={restrictToOrgRoles} - autocompleteOptions={roleOptions} - restrictTagsToAutocompleteOptions={restrictToOrgRoles} - allowDuplicates={false} - sortTags={true} - size="sm" /> {showFreeformRoleNamesHint @@ -352,6 +333,7 @@ function BuilderRuleRow({ }) { const t = useTranslations(); const [activeTagIndex, setActiveTagIndex] = useState(null); + const { orgId } = useParams(); return (
- ({ - id: name, - text: name - }))} - setTags={(nextTags) => { - const prevRoleTags = rule.roleNames.map((name) => ({ + {restrictToOrgRoles ? ( + ({ id: name, text: name - })); - const next = - typeof nextTags === "function" - ? nextTags(prevRoleTags) - : nextTags; + }))} + buttonText={t("roleMappingAssignRoles")} + mapRolesByName + orgId={orgId as string} + onSelectRoles={(nextTags) => { + let names = [ + ...new Set(nextTags.map((tag) => tag.text)) + ]; - 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 (!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]!]; + } } - if (names.length > 1) { - names = [names[names.length - 1]!]; - } - } - onChange({ - ...rule, - roleNames: names - }); - }} - activeTagIndex={activeTagIndex} - setActiveTagIndex={setActiveTagIndex} - placeholder={ - restrictToOrgRoles - ? t("roleMappingAssignRoles") - : t("roleMappingAssignRolesPlaceholderFreeform") - } - enableAutocomplete={restrictToOrgRoles} - autocompleteOptions={roleOptions} - restrictTagsToAutocompleteOptions={restrictToOrgRoles} - allowDuplicates={false} - sortTags={true} - size="sm" - styleClasses={{ - inlineTagsContainer: "min-w-0 max-w-full" - }} - /> + onChange({ + ...rule, + roleNames: names + }); + }} + /> + ) : ( + ({ + id: name, + text: name + }))} + setTags={(nextTags) => { + const prevRoleTags = rule.roleNames.map( + (name) => ({ + id: name, + text: name + }) + ); + 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" + }} + /> + )}
{showFreeformRoleNamesHint && (

diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx index 791bb9ddd..6c9edc923 100644 --- a/src/components/UptimeAlertSection.tsx +++ b/src/components/UptimeAlertSection.tsx @@ -1,18 +1,5 @@ "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 { Credenza, CredenzaBody, @@ -23,18 +10,32 @@ import { CredenzaHeader, CredenzaTitle } 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 { 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 { toast } from "@app/hooks/useToast"; -import { orgQueries } from "@app/lib/queries"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; 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 { useQuery, useQueryClient } from "@tanstack/react-query"; +import { BellPlus, BellRing } from "lucide-react"; 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 { orgId: string; @@ -64,12 +65,7 @@ export default function UptimeAlertSection({ const [userTags, setUserTags] = useState([]); const [roleTags, setRoleTags] = useState([]); const [emailTags, setEmailTags] = useState([]); - const [activeUserTagIndex, setActiveUserTagIndex] = useState( - null - ); - const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( - null - ); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); @@ -80,27 +76,6 @@ export default function UptimeAlertSection({ 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; async function handleSubmit() { @@ -227,10 +202,16 @@ export default function UptimeAlertSection({

- +
@@ -240,65 +221,53 @@ export default function UptimeAlertSection({ setName(e.target.value)} - placeholder={t("uptimeAlertNamePlaceholder")} + onChange={(e) => + setName(e.target.value) + } + placeholder={t( + "uptimeAlertNamePlaceholder" + )} />
- - { - const next = - typeof newTags === "function" - ? newTags(userTags) - : newTags; - setUserTags(next as Tag[]); - }} - enableAutocomplete - autocompleteOptions={allUsers} - restrictTagsToAutocompleteOptions - allowDuplicates={false} - sortTags + +
- - { - const next = - typeof newTags === "function" - ? newTags(roleTags) - : newTags; - setRoleTags(next as Tag[]); - }} - enableAutocomplete - autocompleteOptions={allRoles} - restrictTagsToAutocompleteOptions - allowDuplicates={false} - sortTags + +
- + { const next = - typeof newTags === "function" + typeof newTags === + "function" ? newTags(emailTags) : newTags; setEmailTags(next as Tag[]); @@ -306,7 +275,9 @@ export default function UptimeAlertSection({ allowDuplicates={false} sortTags validateTag={(tag) => - /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( + tag + ) } delimiterList={[",", "Enter"]} /> diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index b374df5f8..d787595ed 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -1,5 +1,8 @@ "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 { Checkbox } from "@app/components/ui/checkbox"; import { @@ -21,11 +24,13 @@ import { import { Input } from "@app/components/ui/input"; import { Switch } from "@app/components/ui/switch"; import { Textarea } from "@app/components/ui/textarea"; +import { Label } from "@app/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Select, SelectContent, @@ -33,24 +38,21 @@ import { SelectTrigger, SelectValue } 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 { type AlertRuleFormAction, type AlertRuleFormValues } from "@app/lib/alertRuleForm"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; -import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useRef, useState } from "react"; import type { Control, UseFormReturn } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form"; import { useDebounce } from "use-debounce"; +import { RolesSelector } from "../roles-selector"; +import { UsersSelector } from "../users-selector"; export function AddActionPanel({ onAdd @@ -498,12 +500,6 @@ function NotifyActionFields({ const t = useTranslations(); const [emailActiveIdx, setEmailActiveIdx] = useState(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery( orgQueries.users({ orgId }) @@ -574,14 +570,6 @@ function NotifyActionFields({ hasResolvedTagsRef.current = true; }, [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({ control, name: `actions.${index}.emailTags` @@ -596,29 +584,16 @@ function NotifyActionFields({ {t("alertingNotifyUsers")} - { - const next = - typeof newTags === "function" - ? newTags(userTags) - : newTags; + { form.setValue( `actions.${index}.userTags`, - next as Tag[], + newUsers as [Tag, ...Tag[]], { shouldDirty: true } ); }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} /> @@ -632,29 +607,17 @@ function NotifyActionFields({ {t("alertingNotifyRoles")} - { - const next = - typeof newTags === "function" - ? newTags(roleTags) - : newTags; + { form.setValue( `actions.${index}.roleTags`, - next as Tag[], + newUsers as [Tag, ...Tag[]], { shouldDirty: true } ); }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} /> diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index 99515135e..cfae4c2d8 100644 --- a/src/components/machines-selector.tsx +++ b/src/components/machines-selector.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; import { useTranslations } from "next-intl"; -import { MultiSelectTags } from "./multi-select-tags"; +import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input"; export type SelectedMachine = Pick< ListClientsResponse["clients"][number], @@ -28,11 +28,13 @@ export function MachinesSelector({ const [debouncedValue] = useDebounce(machineSearchQuery, 150); + const perPage = 7; + 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 allMachines: Array = [...machines]; if (debouncedValue.trim().length === 0) { @@ -44,75 +46,32 @@ export function MachinesSelector({ } } } - return allMachines; }, [machines, selectedMachines, debouncedValue]); - // const selectedMachinesIds = new Set( - // selectedMachines.map((m) => m.clientId) - // ); - return ( - ({ - ...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} + 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 + })) + ); + }} /> - // - // - // - // {t("machineNotFound")} - // - // {machinesShown.map((m) => ( - // { - // let newMachineClients = []; - // if (selectedMachinesIds.has(m.clientId)) { - // newMachineClients = selectedMachines.filter( - // (mc) => mc.clientId !== m.clientId - // ); - // } else { - // newMachineClients = [ - // ...selectedMachines, - // m - // ]; - // } - // onSelectMachines(newMachineClients); - // }} - // > - // - // {`${m.name}`} - // - // ))} - // - // - // ); } diff --git a/src/components/multi-select-tags.tsx b/src/components/multi-select/multi-select-content.tsx similarity index 83% rename from src/components/multi-select-tags.tsx rename to src/components/multi-select/multi-select-content.tsx index 2fb9b097d..9f49b41ca 100644 --- a/src/components/multi-select-tags.tsx +++ b/src/components/multi-select/multi-select-content.tsx @@ -6,24 +6,26 @@ import { CommandInput, CommandItem, CommandList -} from "./ui/command"; +} from "../ui/command"; import { cn } from "@app/lib/cn"; import { CheckIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; export type TagValue = { text: string; id: string }; export type MultiSelectTagsProps = { - emptyPlaceholder: string; - searchPlaceholder: string; + emptyPlaceholder?: string; + searchPlaceholder?: string; searchQuery?: string; options: Array; value: Array; onChange: (newValue: Array) => void; onSearch: (query: string) => void; ref?: Ref; + disabled?: boolean; }; -export function MultiSelectTags({ +export function MultiSelectContent({ emptyPlaceholder, searchPlaceholder, searchQuery, @@ -32,16 +34,19 @@ export function MultiSelectTags({ onSearch, onChange }: MultiSelectTagsProps) { + const t = useTranslations(); const selectedValues = new Set(value.map((v) => v.id)); return ( - {emptyPlaceholder} + + {emptyPlaceholder ?? t("noResults")} + {options.map((option) => ( extends MultiSelectTagsProps { + buttonText?: string; +} + +export function MultiSelectTagInput({ + buttonText, + ...props +}: MultiSelectInputProps) { + const selectedValues = new Set(props.value.map((v) => v.id)); + + return ( + { + if (!open) { + // clear input when popover is closed + props.onSearch(""); + } + }} + > + +
+ + {props.value.map((option) => ( + e.stopPropagation()} + > + {option.text} + + + ))} + {buttonText} + + +
+
+ + + +
+ ); +} diff --git a/src/components/roles-selector.tsx b/src/components/roles-selector.tsx new file mode 100644 index 000000000..7f1b62e60 --- /dev/null +++ b/src/components/roles-selector.tsx @@ -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 = 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 ( + + ); +} diff --git a/src/components/tags/autocomplete.tsx b/src/components/tags/autocomplete.tsx index 916e7aeed..938853a1d 100644 --- a/src/components/tags/autocomplete.tsx +++ b/src/components/tags/autocomplete.tsx @@ -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 { Command, @@ -220,7 +226,7 @@ export const Autocomplete: React.FC = ({ >
{childrenWithProps} @@ -260,10 +266,7 @@ export const Autocomplete: React.FC = ({ side="bottom" align="start" forceMount - className={cn( - "p-0", - classStyleProps?.popoverContent - )} + className={cn("p-0", classStyleProps?.popoverContent)} style={{ width: `${popoverWidth}px`, minWidth: `${popoverWidth}px`, @@ -300,7 +303,9 @@ export const Autocomplete: React.FC = ({ key={option.id} value={`${option.text} ${option.id}`} onSelect={() => toggleTag(option)} - className={classStyleProps?.commandItem} + className={ + classStyleProps?.commandItem + } > boolean; direction?: "row" | "column"; onInputChange?: (value: string) => void; + searchQuery?: string; + onSearchQueryChange?: (value: string) => void; customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; @@ -157,10 +159,24 @@ export function TagInput({ ref, ...props }: TagInputProps) { disabled = false, usePortal = false, addOnPaste = false, - generateTagId = uuid + generateTagId = uuid, + searchQuery, + onSearchQueryChange } = props; const [inputValue, setInputValue] = React.useState(""); + const isControlled = searchQuery !== undefined; + const effectiveQuery = isControlled ? searchQuery : inputValue; + + const updateQuery = React.useCallback( + (action: React.SetStateAction) => { + 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 inputRef = React.useRef(null); @@ -234,9 +250,9 @@ export function TagInput({ ref, ...props }: TagInputProps) { ); } }); - setInputValue(""); + updateQuery(""); } else { - setInputValue(newValue); + updateQuery(newValue); } onInputChange?.(newValue); }; @@ -247,8 +263,8 @@ export function TagInput({ ref, ...props }: TagInputProps) { }; const handleInputBlur = (event: React.FocusEvent) => { - if (addTagsOnBlur && inputValue.trim()) { - const newTagText = inputValue.trim(); + if (addTagsOnBlur && effectiveQuery.trim()) { + const newTagText = effectiveQuery.trim(); if (validateTag && !validateTag(newTagText)) { return; @@ -273,7 +289,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { setTags([...tags, { id: newTagId, text: newTagText }]); onTagAdd?.(newTagText); setTagCount((prevTagCount) => prevTagCount + 1); - setInputValue(""); + updateQuery(""); } } @@ -287,7 +303,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { : e.key === delimiter || e.key === Delimiter.Enter ) { e.preventDefault(); - const newTagText = inputValue.trim(); + const newTagText = effectiveQuery.trim(); // Check if the tag is in the autocomplete options if restrictTagsToAutocomplete is true if ( @@ -329,7 +345,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { onTagAdd?.(newTagText); setTagCount((prevTagCount) => prevTagCount + 1); } - setInputValue(""); + updateQuery(""); } else { switch (e.key) { case "Delete": @@ -419,9 +435,6 @@ export function TagInput({ ref, ...props }: TagInputProps) { onClearAll?.(); }; - // const filteredAutocompleteOptions = autocompleteFilter - // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) - // : autocompleteOptions; const displayedTags = sortTags ? [...tags].sort() : tags; const truncatedTags = truncate @@ -436,13 +449,15 @@ export function TagInput({ ref, ...props }: TagInputProps) { return (
0 ? "gap-3" : ""} ${ + className={cn( + `w-full flex`, + !inlineTags && tags.length > 0 && "gap-3", inputFieldPosition === "bottom" ? "flex-col" : inputFieldPosition === "top" ? "flex-col-reverse" : "flex-row" - }`} + )} > {!usePopoverForTags && (!inlineTags ? ( @@ -515,14 +530,14 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} 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, styleClasses?.input )} @@ -544,16 +559,17 @@ export function TagInput({ ref, ...props }: TagInputProps) {
) ))} + {enableAutocomplete ? (
= maxTags ? placeholderWhenFull : placeholder} // ref={inputRef} - // value={inputValue} + // value={effectiveQuery} // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} // onChangeCapture={handleInputChange} // onKeyDown={handleKeyDown} @@ -601,14 +617,14 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} 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, styleClasses?.input )} @@ -662,7 +678,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { {/* = maxTags ? placeholderWhenFull : placeholder} ref={inputRef} - value={inputValue} + value={effectiveQuery} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} onChangeCapture={handleInputChange} onKeyDown={handleKeyDown} @@ -685,14 +701,14 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} 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, styleClasses?.input )} @@ -741,7 +757,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { {/* = maxTags ? placeholderWhenFull : placeholder} ref={inputRef} - value={inputValue} + value={effectiveQuery} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} onChangeCapture={handleInputChange} onKeyDown={handleKeyDown} @@ -763,14 +779,14 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur} {...inputProps} 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, styleClasses?.input )} @@ -806,7 +822,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -866,7 +882,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={inputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 8b2b6748a..eab0f517a 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -87,7 +87,7 @@ function CommandList({ ) { return ( ); @@ -115,7 +116,7 @@ function CommandGroup({ ({ ))} - {table.getRowModel().rows?.length ? ( + {(table.getRowModel().rows ?? []).length > 0 ? ( table.getRowModel().rows.map((row) => ( 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 = 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 ( + + ); +} diff --git a/src/lib/getUserDisplayName.ts b/src/lib/getUserDisplayName.ts index e95096c16..508b198c3 100644 --- a/src/lib/getUserDisplayName.ts +++ b/src/lib/getUserDisplayName.ts @@ -8,6 +8,7 @@ type UserDisplayNameInput = email?: string | null; name?: string | null; username?: string | null; + idpName?: string | null; }; /** @@ -21,16 +22,25 @@ export function getUserDisplayName(input: UserDisplayNameInput): string { let email: string | null | undefined; let name: string | null | undefined; let username: string | null | undefined; + let idpName: string | null | undefined; if ("user" in input) { email = input.user.email; name = input.user.name; username = input.user.username; + idpName = input.user.idpName; } else { email = input.email; name = input.name; username = input.username; + idpName = input.idpName; } - return email || name || username || ""; + let nameShown = email || name || username || ""; + + if (idpName) { + nameShown = `${nameShown} (${idpName})`; + } + + return nameShown; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 3e38a7ba0..e58a5d471 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -125,24 +125,56 @@ export const orgQueries = { return res.data.data.clients; } }), - users: ({ orgId }: { orgId: string }) => + users: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "USERS"] as const, + queryKey: ["ORG", orgId, "USERS", { query, perPage }] as const, queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + const res = await meta!.api.get< AxiosResponse - >(`/org/${orgId}/users`, { signal }); + >(`/org/${orgId}/users?${sp.toString()}`, { signal }); return res.data.data.users; } }), - roles: ({ orgId }: { orgId: string }) => + roles: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => queryOptions({ - queryKey: ["ORG", orgId, "ROLES"] as const, + queryKey: ["ORG", orgId, "ROLES", { query, perPage }] as const, queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + const res = await meta!.api.get< AxiosResponse - >(`/org/${orgId}/roles`, { signal }); + >(`/org/${orgId}/roles?${sp.toString()}`, { signal }); return res.data.data.roles; }