From 09fea47ce4dce03b9f98a8f3606a081ecefae5bc Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:22:06 -0700 Subject: [PATCH] Fleet UI: Handle long fleet names across the Fleets UI (#49216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue Closes #47290 Also implements the "Cap free-text `maxLength` to the backend column length" pattern established in [#49041 (patterns.md thread)](https://github.com/fleetdm/fleet/pull/49041/files#r3572648691). ## Description Fleet name inputs had no `maxLength` cap and no service-layer length check, so a name >255 chars failed with a raw MySQL `Data too long` error, and several UI surfaces didn't handle long names gracefully. This PR fixes all four manifestations called out in the bug, plus a related label-overflow case on the host details page, and hardens adjacent name inputs across the app. **Frontend fixes for #47290:** - Create/Rename fleet name inputs now cap at 255 characters (matches `teams.name varchar(255)`). - Fleets table Name column uses `LinkCell` with `tooltipTruncate` + `className="w400"` so long names truncate with an ellipsis and full-name tooltip instead of overflowing across the Hosts/Users columns. - Fleet-detail page header (`.team-details__team-header`): h1 gets `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`, `__team-details` gets `min-width: 0; flex: 1`, and `.action-buttons` gets `flex-shrink: 0` + `white-space: nowrap` on buttons so *Manage enroll secrets / Rename / Delete* no longer wrap to a second line when the fleet name is long. - Manage enroll secrets modal body — `__description` gets `overflow-wrap: anywhere; min-width: 0` so a long `{fleet name}` wraps within the modal instead of spilling out the right edge. **Backend fixes for #47290:** - New `fleet.MaxTeamNameLength = 255` constant. - `NewTeam`, `ModifyTeam`, and `ApplyTeamSpecs` now return `fleet.NewInvalidArgumentError("name", "may not exceed 255 characters")` instead of surfacing a raw `Data too long` MySQL error. Covers UI, API, and GitOps entry points. **Broader consistency pass (per [#49041 thread](https://github.com/fleetdm/fleet/pull/49041/files#r3572648691)):** - New shared `MAX_ENTITY_CHAR_LENGTH = 255` constant in `frontend/utilities/constants.tsx`. - Refactored 8 existing files that had ad-hoc `NAME_MAX_LENGTH = 255` / `MAX_LABEL_NAME_LENGTH = 255` locals to use it. - Slotted it into 16 additional `InputField` name/description inputs that were missing a cap (API user, custom variable, certificate, label name + description, pack name + description, and all 5 CA forms — CustomEST, CustomSCEP, Smallstep, Digicert, Hydrant). - Pruned dead FE length validators that can no longer fire now that the DOM cap enforces the limit (certificate modal, custom variable modal, both label helpers, both category modals). Unusual/shorter caps (e.g. `varchar(64)`, custom business rules) still keep their inline validators — silent truncation is only appropriate for the common 255-char norm. **Bonus:** fixed the long-label overflow on the host details Labels card by capping the pill button `max-width` at 300px. ## Screenrecording https://github.com/user-attachments/assets/b917b72e-7437-4d0c-a1a1-c49b4b1c28ba https://github.com/user-attachments/assets/3a3efbb2-09d8-4f47-9fd4-f158b3453b9e https://github.com/user-attachments/assets/73e5e022-dc93-4381-82b3-be9549d050e6 Latest - max width 300px long label: Screenshot 2026-07-23 at 11 29
24 AM ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually Test coverage: - `CreateFleetModal.tests.tsx`, `RenameFleetModal.tests.tsx` — new case per file asserting the name input's `maxLength === 255`. - `AddCertificateModal.tests.tsx`, `Variables.tests.tsx` — the existing "shows too-long error when pasting 256 chars" tests are now unreachable via the DOM cap; converted to `maxLength === 255` assertions. - `ee/server/service/teams_test.go` — `TestNewTeamNameValidation`, `TestModifyTeamNameValidation`, and `TestApplyTeamSpecsNameValidation` each get two new cases (accepts at the limit, rejects one over with the expected error message). ## Summary by CodeRabbit * **Bug Fixes** * Limited fleet, team, and other user-entered names and descriptions to 255 characters. * Replaced database errors for oversized names with clear validation messages. * Prevented long fleet and label names from overflowing tables, headers, modals, and host details. * Improved modal and dropdown layouts for long text. * **Tests** * Added coverage for character limits, boundary values, and multibyte names. --- changes/47290-long-fleet-name-ui | 3 ++ ee/server/service/teams.go | 10 ++++ ee/server/service/teams_test.go | 48 +++++++++++++++++++ .../EnrollSecretModal/_styles.scss | 5 ++ .../forms/packs/EditPackForm/EditPackForm.tsx | 3 ++ .../forms/packs/NewPackForm/NewPackForm.tsx | 3 ++ .../AddCertificateModal.tests.tsx | 16 +++---- .../AddCertificateModal.tsx | 6 ++- .../components/AddCertificateModal/helpers.ts | 8 ---- .../GlobalVariables/GlobalVariables.tests.tsx | 14 ++---- .../AddCustomVariableModal.tsx | 6 ++- .../AddCustomVariableModal/helpers.ts | 7 --- .../AddCategoryModal/AddCategoryModal.tsx | 8 ++-- .../EditCategoryModal/EditCategoryModal.tsx | 8 ++-- .../CustomESTForm/CustomESTForm.tsx | 2 + .../CustomSCEPForm/CustomSCEPForm.tsx | 2 + .../components/DigicertForm/DigicertForm.tsx | 2 + .../components/HydrantForm/HydrantForm.tsx | 2 + .../SmallstepForm/SmallstepForm.tsx | 2 + .../ManageFleetsPage/FleetTableConfig.tsx | 2 + .../TeamDetailsWrapper/_styles.scss | 17 +++++++ .../CreateFleetModal.tests.tsx | 7 +++ .../CreateFleetModal/CreateFleetModal.tsx | 2 + .../RenameFleetModal.tests.tsx | 7 +++ .../RenameFleetModal/RenameFleetModal.tsx | 2 + .../components/ApiUserForm/ApiUserForm.tsx | 2 + .../hosts/details/cards/Labels/_styles.scss | 6 +++ .../labels/NewLabelPage/NewLabelPage.tsx | 45 ++++------------- frontend/pages/labels/NewLabelPage/helpers.ts | 29 +---------- .../labels/components/LabelForm/LabelForm.tsx | 13 ++--- .../labels/components/LabelForm/helpers.ts | 29 +---------- .../edit/components/PolicyForm/PolicyForm.tsx | 9 ++-- .../SaveNewPolicyModal/SaveNewPolicyModal.tsx | 5 +- .../EditQueryForm/EditQueryForm.tsx | 5 +- .../SaveNewQueryModal/SaveNewQueryModal.tsx | 5 +- frontend/utilities/constants.tsx | 7 +++ server/fleet/teams.go | 5 ++ 37 files changed, 192 insertions(+), 160 deletions(-) create mode 100644 changes/47290-long-fleet-name-ui diff --git a/changes/47290-long-fleet-name-ui b/changes/47290-long-fleet-name-ui new file mode 100644 index 0000000000..19a73039b0 --- /dev/null +++ b/changes/47290-long-fleet-name-ui @@ -0,0 +1,3 @@ +- Fixed long fleet names overflowing the fleets table, fleet detail page header, teams dropdown, and manage enroll secrets modal. Fleet name inputs now cap at 255 characters (matching the database column), and the API and GitOps now return a clear validation error instead of a raw "Data too long" MySQL error when a longer name is submitted. +- Added 255-character caps to additional user-supplied name and description inputs (API user, custom variable, certificate, label, pack, certificate authority forms) to prevent the same class of overflow bug. +- Fixed long label names overflowing the Labels card on the host details page. diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index c6ce92e9e9..a4dbe3aec4 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "strings" + "unicode/utf8" "golang.org/x/text/unicode/norm" @@ -96,6 +97,9 @@ func (svc *Service) NewTeam(ctx context.Context, p fleet.TeamPayload) (*fleet.Te if *p.Name == "" { return nil, fleet.NewInvalidArgumentError("name", "may not be empty") } + if utf8.RuneCountInString(*p.Name) > fleet.MaxTeamNameLength { + return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength)) + } if fleet.IsReservedTeamName(*p.Name) { return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", *p.Name)) } @@ -184,6 +188,9 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T if *payload.Name == "" { return nil, fleet.NewInvalidArgumentError("name", "may not be empty") } + if utf8.RuneCountInString(*payload.Name) > fleet.MaxTeamNameLength { + return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength)) + } if fleet.IsReservedTeamName(*payload.Name) { return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", *payload.Name)) } @@ -1326,6 +1333,9 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, if spec.Name == "" { return nil, fleet.NewInvalidArgumentError("name", "name may not be empty") } + if utf8.RuneCountInString(spec.Name) > fleet.MaxTeamNameLength { + return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength)) + } if fleet.IsReservedTeamName(spec.Name) { return nil, fleet.NewInvalidArgumentError("name", fmt.Sprintf("%q is a reserved fleet name", spec.Name)) } diff --git a/ee/server/service/teams_test.go b/ee/server/service/teams_test.go index 6809a00112..9fa8ff93f8 100644 --- a/ee/server/service/teams_test.go +++ b/ee/server/service/teams_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "log/slog" "strings" "testing" @@ -134,6 +135,23 @@ func TestNewTeamNameValidation(t *testing.T) { teamName: ptr.String("Engineering"), wantName: "Engineering", }, + { + name: "name at max length is accepted", + teamName: new(strings.Repeat("a", fleet.MaxTeamNameLength)), + wantName: strings.Repeat("a", fleet.MaxTeamNameLength), + }, + { + name: "name over max length is rejected", + teamName: new(strings.Repeat("a", fleet.MaxTeamNameLength+1)), + wantErr: fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength), + }, + { + // Guards against regressing to byte-based length checks, which + // would reject multibyte names that fit within the character cap. + name: "multibyte name at max character length is accepted", + teamName: new(strings.Repeat("日", fleet.MaxTeamNameLength)), + wantName: strings.Repeat("日", fleet.MaxTeamNameLength), + }, } for _, tc := range testCases { @@ -257,6 +275,21 @@ func TestModifyTeamNameValidation(t *testing.T) { teamName: ptr.String("my team"), wantName: "my team", }, + { + name: "name at max length is accepted", + teamName: new(strings.Repeat("a", fleet.MaxTeamNameLength)), + wantName: strings.Repeat("a", fleet.MaxTeamNameLength), + }, + { + name: "name over max length is rejected", + teamName: new(strings.Repeat("a", fleet.MaxTeamNameLength+1)), + wantErr: fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength), + }, + { + name: "multibyte name at max character length is accepted", + teamName: new(strings.Repeat("日", fleet.MaxTeamNameLength)), + wantName: strings.Repeat("日", fleet.MaxTeamNameLength), + }, } for _, tc := range testCases { @@ -367,6 +400,21 @@ func TestApplyTeamSpecsNameValidation(t *testing.T) { teamName: " Engineering ", wantName: "Engineering", }, + { + name: "name at max length is accepted", + teamName: strings.Repeat("a", fleet.MaxTeamNameLength), + wantName: strings.Repeat("a", fleet.MaxTeamNameLength), + }, + { + name: "name over max length is rejected", + teamName: strings.Repeat("a", fleet.MaxTeamNameLength+1), + wantErr: fmt.Sprintf("may not exceed %d characters", fleet.MaxTeamNameLength), + }, + { + name: "multibyte name at max character length is accepted", + teamName: strings.Repeat("日", fleet.MaxTeamNameLength), + wantName: strings.Repeat("日", fleet.MaxTeamNameLength), + }, } for _, tc := range testCases { diff --git a/frontend/components/EnrollSecrets/EnrollSecretModal/_styles.scss b/frontend/components/EnrollSecrets/EnrollSecretModal/_styles.scss index 8a29d29f63..c529314c5c 100644 --- a/frontend/components/EnrollSecrets/EnrollSecretModal/_styles.scss +++ b/frontend/components/EnrollSecrets/EnrollSecretModal/_styles.scss @@ -8,6 +8,11 @@ color: $ui-error; } + &__description { + min-width: 0; + overflow-wrap: anywhere; + } + .empty-table__container { margin: $pad-large auto $pad-medium; gap: $pad-medium; diff --git a/frontend/components/forms/packs/EditPackForm/EditPackForm.tsx b/frontend/components/forms/packs/EditPackForm/EditPackForm.tsx index 5161e26358..1f61430a4b 100644 --- a/frontend/components/forms/packs/EditPackForm/EditPackForm.tsx +++ b/frontend/components/forms/packs/EditPackForm/EditPackForm.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import useDeepEffect from "hooks/useDeepEffect"; +import { MAX_ENTITY_CHAR_LENGTH } from "utilities/constants"; import Button from "components/buttons/Button"; import { IQuery } from "interfaces/query"; @@ -111,6 +112,7 @@ const EditPackForm = ({ name="name" error={errors.name} inputWrapperClass={`${baseClass}__pack-title`} + inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }} /> { }); }); - it("shows inline error for Name longer than 255 characters as user types", async () => { - const { user } = await renderModal(); + it("caps the Name input at 255 characters (matches DB varchar(255))", async () => { + await renderModal(); - // Paste rather than type to keep the test fast (256 simulated keypresses is slow). - await user.click(screen.getByPlaceholderText(NAME_PLACEHOLDER)); - await user.paste("a".repeat(256)); - - await waitFor(() => { - expect(screen.getByText(NAME_TOO_LONG_MSG)).toBeInTheDocument(); - }); + const nameInput = screen.getByPlaceholderText( + NAME_PLACEHOLDER + ) as HTMLInputElement; + expect(nameInput.maxLength).toBe(255); }); it("submits successfully without SAN (field omitted from request body)", async () => { diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx index a938796002..5532f2468b 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx @@ -2,7 +2,10 @@ import React, { useMemo, useState } from "react"; import { useQuery } from "react-query"; import { SingleValue } from "react-select-5"; -import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; +import { + DEFAULT_USE_QUERY_OPTIONS, + MAX_ENTITY_CHAR_LENGTH, +} from "utilities/constants"; import paths from "router/paths"; @@ -203,6 +206,7 @@ const AddCertModal = ({ helpText="Letters, numbers, spaces, dashes, and underscores only. Name can be used as certificate alias to reference in configuration profiles." parseTarget placeholder="VPN certificate" + inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }} /> { }, message: INVALID_NAME_MSG, }, - { - name: "maxLength", - isValid: (formData: IAddCertFormData) => { - return formData.name.length <= 255; - }, - message: NAME_TOO_LONG_MSG, - }, ], }, certAuthorityId: { diff --git a/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tests.tsx b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tests.tsx index af582d0740..e3b214b350 100644 --- a/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tests.tsx +++ b/frontend/pages/ManageControlsPage/Variables/cards/GlobalVariables/GlobalVariables.tests.tsx @@ -315,17 +315,9 @@ describe("Custom variables", () => { expect(saveButton).toBeDisabled(); }); }); - it("does not allow saving very long name", async () => { - const { nameInput, valueInput, saveButton } = await getAddVariableUI(); - await user.type(nameInput, new Array(256).fill("A").join("")); // Invalid name - await user.type(valueInput, "a value"); - await user.click(saveButton); - await waitFor(() => { - expect( - screen.getByText("Name may not exceed 255 characters") - ).toBeInTheDocument(); - expect(saveButton).toBeDisabled(); - }); + it("caps the name input at 255 characters (matches DB varchar(255))", async () => { + const { nameInput } = await getAddVariableUI(); + expect((nameInput as HTMLInputElement).maxLength).toBe(255); }); }); diff --git a/frontend/pages/ManageControlsPage/Variables/components/AddCustomVariableModal/AddCustomVariableModal.tsx b/frontend/pages/ManageControlsPage/Variables/components/AddCustomVariableModal/AddCustomVariableModal.tsx index f83ed9150f..b2accc5121 100644 --- a/frontend/pages/ManageControlsPage/Variables/components/AddCustomVariableModal/AddCustomVariableModal.tsx +++ b/frontend/pages/ManageControlsPage/Variables/components/AddCustomVariableModal/AddCustomVariableModal.tsx @@ -4,7 +4,10 @@ import Button from "components/buttons/Button"; import { IVariableFormData } from "interfaces/variables"; import { hasStatusKey, getErrorReason } from "interfaces/errors"; import variablesAPI from "services/entities/variables"; -import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; +import { + LEARN_MORE_ABOUT_BASE_LINK, + MAX_ENTITY_CHAR_LENGTH, +} from "utilities/constants"; import { notify } from "components/ToastNotification"; import CustomLink from "components/CustomLink"; import InputField from "components/forms/fields/InputField"; @@ -117,6 +120,7 @@ const AddCustomVariableModal = ({ } error={formValidation.name?.message} + inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }} /> { - return formData.name.length <= 255; - }, - message: "Name may not exceed 255 characters", - }, { name: "doesNotIncludePrefix", isValid: (formData: IAddCustomVariableFormData) => { diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/AddCategoryModal.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/AddCategoryModal.tsx index 5cbf11adc5..3e479139fe 100644 --- a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/AddCategoryModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/AddCategoryModal/AddCategoryModal.tsx @@ -2,13 +2,13 @@ import React, { useState } from "react"; import selfServiceCategoriesAPI from "services/entities/self_service_categories"; import { hasStatusKey } from "interfaces/errors"; +import { MAX_ENTITY_CHAR_LENGTH } from "utilities/constants"; import Button from "components/buttons/Button"; import InputField from "components/forms/fields/InputField"; import Modal from "components/Modal"; const baseClass = "add-category-modal"; -const NAME_MAX_LENGTH = 255; interface IAddCategoryModalProps { fleetId: number; @@ -26,9 +26,7 @@ const AddCategoryModal = ({ const [isSubmitting, setIsSubmitting] = useState(false); const trimmedName = name.trim(); - const isInvalid = - trimmedName.length === 0 || trimmedName.length > NAME_MAX_LENGTH; - const isDisabled = isInvalid || isSubmitting; + const isDisabled = trimmedName.length === 0 || isSubmitting; const onNameChange = (value: string) => { setName(value); @@ -74,7 +72,7 @@ const AddCategoryModal = ({ error={error} autofocus ignore1password - inputOptions={{ maxLength: NAME_MAX_LENGTH }} + inputOptions={{ maxLength: MAX_ENTITY_CHAR_LENGTH }} />