fleet/frontend/components/forms/RegistrationForm/OrgDetails/OrgDetails.tsx
Nico b4a207fb5a
Add ability to upload custom org logos (#44390)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #44330, Resolves #44331

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests. (I'd defer integration tests to a
separate PR since this one is pretty large already.)

- [x] QA'd all new/changed functionality manually. I've tested this on
both the setup flow and the organization settings page. I haven't had
the time to test this on other places where we render the logo (macOS
setup experience / MDM migration dialog).


https://github.com/user-attachments/assets/95d4eae5-3da6-40f4-98a1-8575b97d96b3

## New Fleet configuration settings

- [x] Setting(s) is/are explicitly excluded from GitOps.

Will handle GitOps in a separate PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Organizations can upload custom logos for light and dark modes.
* Registration and Org Settings support logo file upload, preview,
per-mode replace/delete, and validation (size & image formats).
* Activity feed records logo changes/deletions; site nav displays
uploaded logos per theme.
* File uploader/preview adds a Fleet logo graphic option and improved
logo validation.
  * Config/GitOps outputs now include separate dark/light logo fields.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-05 14:42:52 +02:00

151 lines
3.9 KiB
TypeScript

import React, { useEffect, useRef, useState } from "react";
import {
ORG_LOGO_ACCEPT,
ORG_LOGO_HELP_TEXT,
validateOrgLogoFile,
} from "utilities/file/orgLogoFile";
import Button from "components/buttons/Button";
import FileUploader from "components/FileUploader";
import InputField from "components/forms/fields/InputField";
interface IOrgDetailsFormData {
org_name: string;
org_logo_file?: File | null;
}
interface IOrgDetailsErrors {
org_name?: string;
org_logo_file?: string;
}
interface IOrgDetailsProps {
className?: string;
currentPage: boolean;
formData?: Partial<IOrgDetailsFormData>;
handleSubmit: (formData: IOrgDetailsFormData) => void;
}
interface ICustomLogo {
file: File;
url: string;
}
const OrgDetails = ({
className,
currentPage,
formData,
handleSubmit,
}: IOrgDetailsProps) => {
const [orgName, setOrgName] = useState<string>(formData?.org_name || "");
const [customLogo, setCustomLogo] = useState<ICustomLogo | null>(() => {
if (!formData?.org_logo_file) return null;
return {
file: formData.org_logo_file,
url: URL.createObjectURL(formData.org_logo_file),
};
});
const [errors, setErrors] = useState<IOrgDetailsErrors>({});
// Revoke every live blob URL on unmount
const activePreviewUrlRef = useRef<string | null>(customLogo?.url ?? null);
useEffect(() => {
activePreviewUrlRef.current = customLogo?.url ?? null;
}, [customLogo]);
useEffect(
() => () => {
if (activePreviewUrlRef.current) {
URL.revokeObjectURL(activePreviewUrlRef.current);
}
},
[]
);
const onOrgNameChange = (value: string) => {
setOrgName(value);
setErrors((prev) => ({ ...prev, org_name: undefined }));
};
const onFileSelect = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
const result = await validateOrgLogoFile(file);
if (!result.valid) {
setErrors((prev) => ({ ...prev, org_logo_file: result.error }));
return;
}
setErrors((prev) => ({ ...prev, org_logo_file: undefined }));
const url = URL.createObjectURL(file);
setCustomLogo((prev) => {
if (prev) URL.revokeObjectURL(prev.url);
return { file, url };
});
};
const onDeleteFile = () => {
setCustomLogo((prev) => {
if (prev) URL.revokeObjectURL(prev.url);
return null;
});
setErrors((prev) => ({ ...prev, org_logo_file: undefined }));
};
const onSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
if (!orgName) {
setErrors((prev) => ({
...prev,
org_name: "Organization name must be present",
}));
return;
}
handleSubmit({
org_name: orgName,
org_logo_file: customLogo?.file ?? null,
});
};
return (
<form onSubmit={onSubmit} className={className} autoComplete="off">
<InputField
label="Organization name"
name="org_name"
value={orgName}
onChange={onOrgNameChange}
error={errors.org_name}
autofocus={!!currentPage}
/>
<FileUploader
label="Organization logo (optional)"
graphicName="fleet-logo"
accept={ORG_LOGO_ACCEPT}
message={ORG_LOGO_HELP_TEXT}
buttonMessage="Choose file"
onFileUpload={onFileSelect}
onDeleteFile={onDeleteFile}
canEdit
fileDetails={customLogo ? { name: customLogo.file.name } : undefined}
customPreview={
customLogo ? (
<img
src={customLogo.url}
alt="Organization logo preview"
width={40}
height={40}
style={{ objectFit: "contain" }}
/>
) : undefined
}
internalError={errors.org_logo_file}
/>
<div className="button-wrap--center">
<Button type="submit" disabled={!currentPage} size="wide">
Next
</Button>
</div>
</form>
);
};
export default OrgDetails;