From 9db5ff9ff73c885959e1e42977345f4a9a2001ec Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 23 Apr 2026 04:22:18 +0200 Subject: [PATCH 01/28] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20small=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/controlled-data-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 58081783a..5f17f1887 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -563,7 +563,7 @@ export function ControlledDataTable({ ))} - {table.getRowModel().rows?.length ? ( + {(table.getRowModel().rows ?? []).length > 0 ? ( table.getRowModel().rows.map((row) => ( Date: Thu, 23 Apr 2026 05:17:33 +0200 Subject: [PATCH 02/28] =?UTF-8?q?=F0=9F=8C=90=20update=20french=20translat?= =?UTF-8?q?ions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/fr-FR.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 8b9cd90b9..f2ed46e2c 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1334,7 +1334,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", @@ -2430,8 +2430,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", @@ -3123,6 +3123,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é", From b9bee2836b1973fbd31ad060ee60c2061c4da295 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 23 Apr 2026 06:33:57 +0200 Subject: [PATCH 03/28] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/InternalResourceForm.tsx | 124 ++++++++++-------- src/components/machines-selector.tsx | 2 +- .../multi-select/multi-select-input.tsx | 61 +++++++++ .../{ => multi-select}/multi-select-tags.tsx | 2 +- 4 files changed, 135 insertions(+), 54 deletions(-) create mode 100644 src/components/multi-select/multi-select-input.tsx rename src/components/{ => multi-select}/multi-select-tags.tsx (99%) diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 3a693d82b..559da4cce 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -40,7 +40,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UserType } from "@server/types/UserTypes"; 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 { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -50,7 +55,7 @@ import { formatMultiSitesSelectorLabel } from "./multi-site-selector"; import type { Selectedsite } from "./site-selector"; -import { CaretSortIcon } from "@radix-ui/react-icons"; + import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; import { SwitchInput } from "@app/components/SwitchInput"; @@ -155,7 +160,7 @@ export type InternalResourceData = { const tagSchema = z.object({ id: z.string(), text: z.string() }); function buildSelectedSitesForResource( - resource: InternalResourceData, + resource: InternalResourceData ): Selectedsite[] { return resource.siteIds.map((siteId, idx) => ({ name: resource.siteNames[idx] ?? "", @@ -608,9 +613,7 @@ export function InternalResourceForm({ users: [], clients: [] }); - setSelectedSites( - buildSelectedSitesForResource(resource) - ); + setSelectedSites(buildSelectedSitesForResource(resource)); setTcpPortMode( getPortModeFromString(resource.tcpPortRangeString) ); @@ -877,7 +880,9 @@ export function InternalResourceForm({ field.value ?? "http" } - disabled={httpSectionDisabled} + disabled={ + httpSectionDisabled + } > @@ -918,7 +923,10 @@ export function InternalResourceForm({ @@ -974,7 +982,9 @@ export function InternalResourceForm({ field.value ?? "" } - disabled={httpSectionDisabled} + disabled={ + httpSectionDisabled + } onChange={(e) => { const raw = e.target @@ -1009,7 +1019,9 @@ export function InternalResourceForm({ {isHttpMode && ( - + )} {isHttpMode ? ( @@ -1022,55 +1034,61 @@ export function InternalResourceForm({ {t(httpConfigurationDescriptionKey)} -
- { - if (res === null) { + > + { + if (res === null) { + form.setValue( + "httpConfigSubdomain", + null + ); + form.setValue( + "httpConfigDomainId", + null + ); + form.setValue( + "httpConfigFullDomain", + null + ); + return; + } form.setValue( "httpConfigSubdomain", - null + res.subdomain ?? null ); form.setValue( "httpConfigDomainId", - null + res.domainId ); form.setValue( "httpConfigFullDomain", - null + res.fullDomain ); - return; - } - form.setValue( - "httpConfigSubdomain", - res.subdomain ?? null - ); - form.setValue( - "httpConfigDomainId", - res.domainId - ); - form.setValue( - "httpConfigFullDomain", - res.fullDomain - ); - }} - /> + }} + />
@@ -1511,7 +1531,7 @@ export function InternalResourceForm({ role="combobox" className={cn( "justify-between w-full", - "text-muted-foreground pl-1.5" + "text-muted-foreground pl-1.5 cursor-text" )} > - + diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index 99515135e..f6ef463b8 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 { MultiSelectTags } from "./multi-select/multi-select-tags"; export type SelectedMachine = Pick< ListClientsResponse["clients"][number], diff --git a/src/components/multi-select/multi-select-input.tsx b/src/components/multi-select/multi-select-input.tsx new file mode 100644 index 000000000..d0d8e2873 --- /dev/null +++ b/src/components/multi-select/multi-select-input.tsx @@ -0,0 +1,61 @@ +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { ChevronDownIcon } from "lucide-react"; +import { + type TagValue, + type MultiSelectTagsProps, + MultiSelectTags +} from "./multi-select-tags"; + +export interface MultiSelectInputProps< + T extends TagValue +> extends MultiSelectTagsProps { + buttonText?: string; +} + +export function MultiSelectInput({ + buttonText, + ...props +}: MultiSelectInputProps) { + return ( + + +
+ + {/* {(field.value ?? []).map((client) => ( + + {client.name} + + ))} */} + {buttonText} + + +
+
+ + + +
+ ); +} diff --git a/src/components/multi-select-tags.tsx b/src/components/multi-select/multi-select-tags.tsx similarity index 99% rename from src/components/multi-select-tags.tsx rename to src/components/multi-select/multi-select-tags.tsx index 2fb9b097d..67ce9e8f5 100644 --- a/src/components/multi-select-tags.tsx +++ b/src/components/multi-select/multi-select-tags.tsx @@ -6,7 +6,7 @@ import { CommandInput, CommandItem, CommandList -} from "./ui/command"; +} from "../ui/command"; import { cn } from "@app/lib/cn"; import { CheckIcon } from "lucide-react"; From c746e1bc8dd9f2f2181f517ab9f86e45c17f342d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 24 Apr 2026 08:33:43 +0200 Subject: [PATCH 04/28] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/InternalResourceForm.tsx | 19 ++- src/components/machines-selector.tsx | 102 ++++++++++++---- src/components/tags/autocomplete.tsx | 47 +++++-- src/components/tags/tag-input.tsx | 156 +++++++++++++++++++----- 4 files changed, 257 insertions(+), 67 deletions(-) diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 5e87509e6..f63192129 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -1572,7 +1572,22 @@ export function InternalResourceForm({ {t("machineClients")} - + { + form.setValue( + "clients", + machines + ); + }} + /> + {/* + + + + + + + + {t("noResults")} + + {suggestedOptions.map((option) => { + const isChosen = tags.some( + (tag) => tag.text === option.text + ); + return ( + toggleTag(option)} + className={ + styleClasses?.autoComplete + ?.commandItem + } + > + + {option.text} + + ); + })} + + + + + + ); +} diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index b20787e0c..3241912c5 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -88,7 +88,6 @@ export interface TagInputProps searchQuery?: string; onSearchQueryChange?: (value: string) => void; autocompleteContent?: React.ReactNode; - suggestedOptions?: Tag[]; customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; @@ -164,8 +163,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { generateTagId = uuid, searchQuery, onSearchQueryChange, - autocompleteContent, - suggestedOptions + autocompleteContent } = props; const [inputValue, setInputValue] = React.useState(""); @@ -196,7 +194,6 @@ export function TagInput({ ref, ...props }: TagInputProps) { } const handleInputChange = (e: React.ChangeEvent) => { - if (suggestedOptions !== undefined) return; const newValue = e.target.value; if (addOnPaste && newValue.includes(delimiter)) { const splitValues = newValue @@ -440,14 +437,6 @@ export function TagInput({ ref, ...props }: TagInputProps) { onClearAll?.(); }; - const mainInputValue = - suggestedOptions !== undefined ? "" : effectiveQuery; - - const useAutocompleteComponent = - enableAutocomplete || suggestedOptions !== undefined; - const resolvedAutocompleteOptions = suggestedOptions ?? autocompleteOptions; - const disableAutocompleteSearch = suggestedOptions !== undefined; - const displayedTags = sortTags ? [...tags].sort() : tags; const truncatedTags = truncate @@ -500,7 +489,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { disabled={disabled} /> ) : ( - !useAutocompleteComponent && !autocompleteContent && ( + !enableAutocomplete && !autocompleteContent && (
) ))} - {!useAutocompleteComponent && autocompleteContent && ( + {!enableAutocomplete && autocompleteContent && (
)} - {useAutocompleteComponent ? ( + {enableAutocomplete ? (
= maxTags ? placeholderWhenFull : placeholder} // ref={inputRef} - // value={mainInputValue} + // value={effectiveQuery} // disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} // onChangeCapture={handleInputChange} // onKeyDown={handleKeyDown} @@ -697,7 +680,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -758,7 +741,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { {/* = maxTags ? placeholderWhenFull : placeholder} ref={inputRef} - value={mainInputValue} + value={effectiveQuery} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} onChangeCapture={handleInputChange} onKeyDown={handleKeyDown} @@ -781,7 +764,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -837,7 +820,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { {/* = maxTags ? placeholderWhenFull : placeholder} ref={inputRef} - value={mainInputValue} + value={effectiveQuery} disabled={disabled || (maxTags !== undefined && tags.length >= maxTags)} onChangeCapture={handleInputChange} onKeyDown={handleKeyDown} @@ -859,7 +842,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -902,7 +885,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} @@ -962,7 +945,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { ? placeholderWhenFull : placeholder } - value={mainInputValue} + value={effectiveQuery} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={handleInputFocus} From 955aa41f53556a2d5c60eef29f64ea3b5ce14e81 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 25 Apr 2026 04:47:17 +0200 Subject: [PATCH 06/28] =?UTF-8?q?=E2=8F=AA=20revert=20changes=20modifying?= =?UTF-8?q?=20existing=20tag=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/tags/suggestions-tag-input.tsx | 266 ------------------ src/components/tags/tag-input.tsx | 69 +---- 2 files changed, 3 insertions(+), 332 deletions(-) delete mode 100644 src/components/tags/suggestions-tag-input.tsx diff --git a/src/components/tags/suggestions-tag-input.tsx b/src/components/tags/suggestions-tag-input.tsx deleted file mode 100644 index decb1d444..000000000 --- a/src/components/tags/suggestions-tag-input.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import { type VariantProps } from "class-variance-authority"; -import { Check } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Popover, - PopoverAnchor, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { Button } from "@app/components/ui/button"; -import { cn } from "@app/lib/cn"; -import { tagVariants } from "./tag"; -import { TagList } from "./tag-list"; -import type { Tag, TagInputStyleClassesProps } from "./tag-input"; - -export type SuggestionsTagInputProps = { - tags: Tag[]; - setTags: React.Dispatch>; - suggestedOptions: Tag[]; - searchQuery: string; - onSearchQueryChange: (value: string) => void; - activeTagIndex: number | null; - setActiveTagIndex: React.Dispatch>; - placeholder?: string; - maxTags?: number; - onTagAdd?: (tag: string) => void; - onTagRemove?: (tag: string) => void; - allowDuplicates?: boolean; - disabled?: boolean; - usePortal?: boolean; - styleClasses?: TagInputStyleClassesProps; -} & VariantProps; - -export function SuggestionsTagInput({ - tags, - setTags, - suggestedOptions, - searchQuery, - onSearchQueryChange, - activeTagIndex, - setActiveTagIndex, - placeholder, - maxTags, - onTagAdd, - onTagRemove, - allowDuplicates = false, - disabled = false, - usePortal = false, - styleClasses = {}, - variant, - size, - shape, - borderStyle, - textCase, - interaction, - animation, - textStyle -}: SuggestionsTagInputProps) { - const t = useTranslations(); - const triggerRef = useRef(null); - const popoverContentRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const [popoverWidth, setPopoverWidth] = useState(0); - - useEffect(() => { - const handleOutsideClick = (event: MouseEvent | TouchEvent) => { - if ( - isOpen && - triggerRef.current && - popoverContentRef.current && - !triggerRef.current.contains(event.target as Node) && - !popoverContentRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handleOutsideClick); - return () => - document.removeEventListener("mousedown", handleOutsideClick); - }, [isOpen]); - - const handleOpenChange = (open: boolean) => { - if (open && triggerRef.current) { - setPopoverWidth(triggerRef.current.getBoundingClientRect().width); - } - if (open) setIsOpen(true); - }; - - const toggleTag = (option: Tag) => { - const index = tags.findIndex((tag) => tag.text === option.text); - if (index >= 0) { - setTags(tags.filter((_, i) => i !== index)); - onTagRemove?.(option.text); - } else { - if ( - !allowDuplicates && - tags.some((tag) => tag.text === option.text) - ) - return; - if (!maxTags || tags.length < maxTags) { - setTags([...tags, option]); - onTagAdd?.(option.text); - } - } - }; - - const removeTag = (idToRemove: string) => { - const removed = tags.find((tag) => tag.id === idToRemove); - setTags(tags.filter((tag) => tag.id !== idToRemove)); - if (removed) onTagRemove?.(removed.text); - }; - - const onSortEnd = (oldIndex: number, newIndex: number) => { - setTags((current) => { - const next = [...current]; - const [moved] = next.splice(oldIndex, 1); - next.splice(newIndex, 0, moved); - return next; - }); - }; - - return ( - - -
- - - - -
-
- - - - - {t("noResults")} - - {suggestedOptions.map((option) => { - const isChosen = tags.some( - (tag) => tag.text === option.text - ); - return ( - toggleTag(option)} - className={ - styleClasses?.autoComplete - ?.commandItem - } - > - - {option.text} - - ); - })} - - - - -
- ); -} diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index 3241912c5..1ea93b468 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -87,7 +87,6 @@ export interface TagInputProps onInputChange?: (value: string) => void; searchQuery?: string; onSearchQueryChange?: (value: string) => void; - autocompleteContent?: React.ReactNode; customTagRenderer?: (tag: Tag, isActiveTag: boolean) => React.ReactNode; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; @@ -162,8 +161,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { addOnPaste = false, generateTagId = uuid, searchQuery, - onSearchQueryChange, - autocompleteContent + onSearchQueryChange } = props; const [inputValue, setInputValue] = React.useState(""); @@ -489,7 +487,7 @@ export function TagInput({ ref, ...props }: TagInputProps) { disabled={disabled} /> ) : ( - !enableAutocomplete && !autocompleteContent && ( + !enableAutocomplete && (
) ))} - {!enableAutocomplete && autocompleteContent && ( -
-
- - = maxTags - ? placeholderWhenFull - : placeholder - } - value={effectiveQuery} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onFocus={handleInputFocus} - onBlur={handleInputBlur} - {...inputProps} - className={cn( - "border-0 px-2 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", - styleClasses?.input - )} - autoComplete="off" - disabled={ - disabled || - (maxTags !== undefined && tags.length >= maxTags) - } - /> -
- {autocompleteContent} -
- )} + {enableAutocomplete ? (
Date: Sat, 25 Apr 2026 04:47:31 +0200 Subject: [PATCH 07/28] =?UTF-8?q?=E2=9C=A8=20new=20multi=20select=20tag=20?= =?UTF-8?q?input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/machines-selector.tsx | 62 ++++++------------- .../multi-select/multi-select-input.tsx | 51 +++++++++++---- 2 files changed, 58 insertions(+), 55 deletions(-) diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index d4aef49d6..8c4757fd0 100644 --- a/src/components/machines-selector.tsx +++ b/src/components/machines-selector.tsx @@ -5,10 +5,7 @@ import { useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; import { useTranslations } from "next-intl"; -import { - SuggestionsTagInput, - type SuggestionsTagInputProps -} from "./tags/suggestions-tag-input"; +import { MultiSelectInput } from "./multi-select/multi-select-input"; export type SelectedMachine = Pick< ListClientsResponse["clients"][number], @@ -19,22 +16,12 @@ export type MachineSelectorProps = { orgId: string; selectedMachines?: SelectedMachine[]; onSelectMachines: (machine: SelectedMachine[]) => void; -} & Omit< - SuggestionsTagInputProps, - | "tags" - | "setTags" - | "suggestedOptions" - | "searchQuery" - | "onSearchQueryChange" - | "activeTagIndex" - | "setActiveTagIndex" ->; +}; export function MachinesSelector({ orgId, selectedMachines = [], - onSelectMachines, - ...props + onSelectMachines }: MachineSelectorProps) { const t = useTranslations(); const [machineSearchQuery, setMachineSearchQuery] = useState(""); @@ -60,42 +47,29 @@ export function MachinesSelector({ return allMachines; }, [machines, selectedMachines, debouncedValue]); - const [activeTagIndex, setActiveTagIndex] = useState(null); - return ( - ({ + ({ id: mc.clientId.toString(), text: mc.name }))} - setTags={(newTags) => { - const tags = - typeof newTags === "function" - ? newTags( - selectedMachines.map((mc) => ({ - id: mc.clientId.toString(), - text: mc.name - })) - ) - : newTags; + value={selectedMachines.map((mc) => ({ + id: mc.clientId.toString(), + text: mc.name + }))} + onChange={(newValues) => { onSelectMachines( - tags.map((tag) => ({ - clientId: Number(tag.id), - name: tag.text + newValues.map((v) => ({ + clientId: Number(v.id), + name: v.text })) ); }} - searchQuery={machineSearchQuery} - onSearchQueryChange={setMachineSearchQuery} - suggestedOptions={machinesShown.map((mc) => ({ - id: mc.clientId.toString(), - text: mc.name - }))} - allowDuplicates={false} /> ); } diff --git a/src/components/multi-select/multi-select-input.tsx b/src/components/multi-select/multi-select-input.tsx index d0d8e2873..4f4fec528 100644 --- a/src/components/multi-select/multi-select-input.tsx +++ b/src/components/multi-select/multi-select-input.tsx @@ -3,14 +3,15 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; -import { Button } from "@app/components/ui/button"; +import { Button, buttonVariants } from "@app/components/ui/button"; import { cn } from "@app/lib/cn"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, XIcon } from "lucide-react"; import { type TagValue, type MultiSelectTagsProps, MultiSelectTags } from "./multi-select-tags"; +import { useState } from "react"; export interface MultiSelectInputProps< T extends TagValue @@ -22,13 +23,20 @@ export function MultiSelectInput({ buttonText, ...props }: MultiSelectInputProps) { + const selectedValues = new Set(props.value.map((v) => v.id)); + return ( - +
({ "overflow-x-auto" )} > - {/* {(field.value ?? []).map((client) => ( + {props.value.map((option) => ( e.stopPropagation()} > - {client.name} + {option.text} + - ))} */} + ))} {buttonText} - +
From 2ea9d272379e6bbb88803b9da42f88f40be6d2bd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 25 Apr 2026 05:26:41 +0200 Subject: [PATCH 08/28] =?UTF-8?q?=E2=9C=A8=20machine=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/machines-selector.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index 8c4757fd0..d4b37bb74 100644 --- a/src/components/machines-selector.tsx +++ b/src/components/machines-selector.tsx @@ -28,8 +28,10 @@ export function MachinesSelector({ const [debouncedValue] = useDebounce(machineSearchQuery, 150); + const perPage = 7; + const { data: machines = [] } = useQuery( - orgQueries.machineClients({ orgId, perPage: 3, query: debouncedValue }) + orgQueries.machineClients({ orgId, perPage, query: debouncedValue }) ); // always include the selected machines in the list (if the user isn't searching) @@ -44,7 +46,7 @@ export function MachinesSelector({ } } } - return allMachines; + return allMachines.slice(0, perPage); }, [machines, selectedMachines, debouncedValue]); return ( From 91ce8bea4bdd01d2ce15409fb8b00ac47502e8e2 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 25 Apr 2026 05:59:43 +0200 Subject: [PATCH 09/28] =?UTF-8?q?=F0=9F=94=A8=20add=20local=20mailer=20for?= =?UTF-8?q?=20catching=20emails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.mailpit.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docker-compose.mailpit.yml 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: From 27b2ec309d18e598d6297fc46a377bea9029ed88 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 25 Apr 2026 06:18:13 +0200 Subject: [PATCH 10/28] =?UTF-8?q?=F0=9F=9A=A7=20users=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/InternalResourceForm.tsx | 21 +++++- src/components/machines-selector.tsx | 6 +- ...lect-tags.tsx => multi-select-content.tsx} | 5 +- ...t-input.tsx => multi-select-tag-input.tsx} | 8 +-- src/components/roles-selector.tsx | 1 + src/components/ui/command.tsx | 2 +- src/components/users-selector.tsx | 64 +++++++++++++++++++ src/lib/queries.ts | 22 ++++++- 8 files changed, 114 insertions(+), 15 deletions(-) rename src/components/multi-select/{multi-select-tags.tsx => multi-select-content.tsx} (93%) rename src/components/multi-select/{multi-select-input.tsx => multi-select-tag-input.tsx} (95%) create mode 100644 src/components/roles-selector.tsx create mode 100644 src/components/users-selector.tsx diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index f63192129..af3841818 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -60,6 +60,7 @@ import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; import { SwitchInput } from "@app/components/SwitchInput"; import CertificateStatus from "@app/components/CertificateStatus"; +import { UsersSelector } from "./users-selector"; // --- Helpers (shared) --- @@ -1522,7 +1523,7 @@ export function InternalResourceForm({ render={({ field }) => ( {t("users")} - + {/* - + */} + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + /> )} diff --git a/src/components/machines-selector.tsx b/src/components/machines-selector.tsx index d4b37bb74..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 { MultiSelectInput } from "./multi-select/multi-select-input"; +import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input"; export type SelectedMachine = Pick< ListClientsResponse["clients"][number], @@ -46,11 +46,11 @@ export function MachinesSelector({ } } } - return allMachines.slice(0, perPage); + return allMachines; }, [machines, selectedMachines, debouncedValue]); return ( - = { ref?: Ref; }; -export function MultiSelectTags({ +export function MultiSelectContent({ emptyPlaceholder, searchPlaceholder, searchQuery, @@ -40,7 +40,8 @@ export function MultiSelectTags({ value={searchQuery} onValueChange={onSearch} /> - + {/* FIXME: why isn't this list scrolling ????? */} + {emptyPlaceholder} {options.map((option) => ( diff --git a/src/components/multi-select/multi-select-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx similarity index 95% rename from src/components/multi-select/multi-select-input.tsx rename to src/components/multi-select/multi-select-tag-input.tsx index 4f4fec528..dfca035a7 100644 --- a/src/components/multi-select/multi-select-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -9,8 +9,8 @@ import { ChevronDownIcon, XIcon } from "lucide-react"; import { type TagValue, type MultiSelectTagsProps, - MultiSelectTags -} from "./multi-select-tags"; + MultiSelectContent +} from "./multi-select-content"; import { useState } from "react"; export interface MultiSelectInputProps< @@ -19,7 +19,7 @@ export interface MultiSelectInputProps< buttonText?: string; } -export function MultiSelectInput({ +export function MultiSelectTagInput({ buttonText, ...props }: MultiSelectInputProps) { @@ -83,7 +83,7 @@ export function MultiSelectInput({
- + ); diff --git a/src/components/roles-selector.tsx b/src/components/roles-selector.tsx new file mode 100644 index 000000000..70b786d12 --- /dev/null +++ b/src/components/roles-selector.tsx @@ -0,0 +1 @@ +// TODO diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 8b2b6748a..67cffeec3 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -87,7 +87,7 @@ function CommandList({ void; +}; + +export function UsersSelector({ + orgId, + selectedUsers = [], + onSelectUsers +}: UsersSelectorProps) { + const t = useTranslations(); + const [userSearchQuery, setUserSearchQuery] = useState(""); + + const [debouncedValue] = useDebounce(userSearchQuery, 150); + + // TODO: switch back to 7 items + const perPage = 1; + + const { data: users = [] } = useQuery( + orgQueries.users({ orgId, perPage, 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/queries.ts b/src/lib/queries.ts index 3e38a7ba0..ab22b5b57 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -125,13 +125,29 @@ 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; } From ddaa9c32a776018ac590cf896861b989e0f2ad84 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 28 Apr 2026 05:08:20 +0200 Subject: [PATCH 11/28] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20replace=20roles=20&?= =?UTF-8?q?=20user=20selectors=20in=20machines=20&=20create=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/[userId]/access-controls/page.tsx | 114 +++++--------- .../settings/access/users/create/page.tsx | 124 ++++++--------- src/components/Credenza.tsx | 2 +- src/components/InternalResourceForm.tsx | 145 ++---------------- src/components/OrgRolesTagField.tsx | 58 +++---- .../multi-select/multi-select-content.tsx | 4 +- .../multi-select/multi-select-tag-input.tsx | 9 +- src/components/roles-selector.tsx | 65 +++++++- src/components/ui/command.tsx | 2 +- src/components/users-selector.tsx | 3 +- src/lib/queries.ts | 22 ++- 11 files changed, 211 insertions(+), 337 deletions(-) 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 - ); - }} - /> - - */} )} 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/multi-select/multi-select-content.tsx b/src/components/multi-select/multi-select-content.tsx index 00a1cf870..0ea54d897 100644 --- a/src/components/multi-select/multi-select-content.tsx +++ b/src/components/multi-select/multi-select-content.tsx @@ -21,6 +21,7 @@ export type MultiSelectTagsProps = { onChange: (newValue: Array) => void; onSearch: (query: string) => void; ref?: Ref; + disabled?: boolean; }; export function MultiSelectContent({ @@ -40,8 +41,7 @@ export function MultiSelectContent({ value={searchQuery} onValueChange={onSearch} /> - {/* FIXME: why isn't this list scrolling ????? */} - + {emptyPlaceholder} {options.map((option) => ( diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index dfca035a7..6b3e0fb0f 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -1,17 +1,16 @@ +import { buttonVariants } from "@app/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; -import { Button, buttonVariants } from "@app/components/ui/button"; import { cn } from "@app/lib/cn"; import { ChevronDownIcon, XIcon } from "lucide-react"; import { - type TagValue, type MultiSelectTagsProps, + type TagValue, MultiSelectContent } from "./multi-select-content"; -import { useState } from "react"; export interface MultiSelectInputProps< T extends TagValue @@ -36,7 +35,8 @@ export function MultiSelectTagInput({ }), "justify-between w-full inline-flex", "text-muted-foreground pl-1.5 cursor-text", - "hover:bg-transparent hover:text-muted-foreground" + "hover:bg-transparent hover:text-muted-foreground", + props.disabled && "pointer-events-none opacity-50" )} > ({ {option.text}