Technician role FE changes (#39494)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #38630

## Testing

- [x] QA'd all new/changed functionality manually

Screenshots below were taken with a **Team Technician** user. Same
changes apply for a **Global Technician**.

#### Controls > OS settings > Disk encryption

- Shows table without controls below.
- Shows empty state (doesn't allow to turn it on).

<img width="1915" height="886" alt="Screenshot 2026-02-10 at 12 24
25 PM"
src="https://github.com/user-attachments/assets/3f44d338-e728-4eb2-ad93-e30844201b52"
/>
<img width="1913" height="907" alt="Screenshot 2026-02-10 at 12 31
38 PM"
src="https://github.com/user-attachments/assets/71706e9e-0540-4c25-b5c0-3f7ccff3ba5a"
/>

#### Controls > OS settings > Custom settings

- Changed description to say **View configuration profiles that apply
custom settings.** instead of **Create and upload configuration profiles
to apply custom settings.**.
- **Add profile** not shown within table header.
- Trash can icon not shown when hovering over a row within the table.
- **Add profile** card not shown on empty state. Instead, "No
configuration profiles have been added." is shown.

<img width="1911" height="729" alt="Screenshot 2026-02-10 at 12 24
39 PM"
src="https://github.com/user-attachments/assets/aa68cbaf-4772-402d-9288-b4be2ddd3250"
/>
<img width="1912" height="650" alt="Screenshot 2026-02-10 at 12 28
48 PM"
src="https://github.com/user-attachments/assets/6a186172-b01f-4314-bb50-4cb533e13bce"
/>

#### Controls > Scripts > Library

- **Add script** not shown within table header.
- No actions shown when hovering over a table row.
- Can view script by clicking on a table row.
- Removed **To run the script across multiple hosts, add a policy
automation on the Policies page** line below **To run this script on a
host, go to the Hosts page and select a host.**.
- Updated copy to `To run this script on a host, go to the Hosts page
and select a host. Then, click Actions > Run script.`

<img width="1912" height="772" alt="Screenshot 2026-02-10 at 12 25
46 PM"
src="https://github.com/user-attachments/assets/83fbc1ec-3a6e-4bb5-865e-b5e7faef1e37"
/>

<img width="1732" height="761" alt="Screenshot 2026-02-11 at 3 50 33 PM"
src="https://github.com/user-attachments/assets/6dda97d7-fde2-4bcd-94b3-fa7368c65528"
/>


#### Labels

Can add label and filter by label

<img width="160" height="247" alt="Screenshot 2026-02-10 at 12 51 24 PM"
src="https://github.com/user-attachments/assets/ed63b708-27f8-4363-9d4f-9a7b0bf82b21"
/>

<img width="1901" height="856" alt="Screenshot 2026-02-10 at 12 35
07 PM"
src="https://github.com/user-attachments/assets/c2ef5e21-03ab-4955-a22f-cd6ca32f3179"
/>

<img width="1903" height="937" alt="Screenshot 2026-02-10 at 12 36
11 PM"
src="https://github.com/user-attachments/assets/d9d9f3bc-4d71-4c4b-902a-455eec9e057c"
/>

Can edit/delete labels created by themselves.
NOTE: my technician user ID is 37 - note that the **x** label belongs to
a different user id, while the second label belongs to ID 37, therefore
it can be edited and deleted.

<img width="1915" height="1152" alt="Screenshot 2026-02-10 at 12 38
29 PM"
src="https://github.com/user-attachments/assets/21f44c11-4e2d-456b-8547-90936b5d7602"
/>
<img width="1911" height="1154" alt="Screenshot 2026-02-10 at 12 38
42 PM"
src="https://github.com/user-attachments/assets/f9f7ea30-11b2-4d2d-9d71-de7299e4b451"
/>

Can delete manual label from host



https://github.com/user-attachments/assets/b64ba6dd-3f54-4dcd-9c57-7bede65122da

#### Host details

Can run scripts and view their results

<img width="1908" height="472" alt="Screenshot 2026-02-10 at 12 52
33 PM"
src="https://github.com/user-attachments/assets/d1e40339-ec52-47ff-bc53-c311498ffe80"
/>
<img width="1882" height="716" alt="Screenshot 2026-02-10 at 12 52
40 PM"
src="https://github.com/user-attachments/assets/dd0c2ec3-8cb8-4835-9c6d-f731a7434637"
/>
<img width="1915" height="718" alt="Screenshot 2026-02-10 at 12 52
48 PM"
src="https://github.com/user-attachments/assets/5e7a73e0-ac5b-4d38-b635-770f53dea9e3"
/>
<img width="1914" height="718" alt="Screenshot 2026-02-10 at 12 52
55 PM"
src="https://github.com/user-attachments/assets/b199c796-66b1-46bc-b2b5-fd35e8aa7a7c"
/>

Can run query associated to host as a live query




https://github.com/user-attachments/assets/7aea6f63-e443-4fa0-87dc-48bef84efa2f

#### Software

Doesn't show trash can icon on software installer card, just the
download one.

<img width="1423" height="838" alt="Screenshot 2026-02-10 at 1 33 53 PM"
src="https://github.com/user-attachments/assets/3a55c226-0bba-43ac-8594-7b5ac0a3684a"
/>

Can install/uninstall software on a host. Note that **Add software**
button is hidden (technicians can't add software).

<img width="1378" height="277" alt="Screenshot 2026-02-10 at 3 08 55 PM"
src="https://github.com/user-attachments/assets/bf413467-2071-48b6-b62b-f3a721b6057c"
/>



#### Queries

- Can run inherited queries on all hosts


https://github.com/user-attachments/assets/09f07e6b-a8c1-453e-81fd-4deb16005836

- Can run team queries on all hosts


https://github.com/user-attachments/assets/18b62dea-e159-40ea-b0ed-1d96b6bd40e7

- Can't manage automations or add queries (buttons are not shown at the
top-right corner)

#### Policies

Same as Queries



https://github.com/user-attachments/assets/2c24514a-2ae0-47a6-b631-6f9e48fc7b9c

#### Protected routes

Tested that I can't access routes that have restricted functionality for
this role, such as:

- **/controls/os-updates**, **/controls/setup-experience** and
**/controls/os-settings/certificates** => redirects to
**/controls/os-settings** 
- **/controls/scripts/progress** => redirects to
**/controls/scripts/library** 
- **/queries/new** and **/software/add/*** => renders access denied page


---------

Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
This commit is contained in:
Nico
2026-02-11 18:38:41 -03:00
committed by GitHub
co-authored by Lucas Manuel Rodriguez
parent 19cdbf940d
commit ffe0f71c83
37 changed files with 660 additions and 290 deletions
@@ -47,6 +47,16 @@ const Scripts = ({ router, location, params }: IScriptsProps) => {
SCRIPTS_NAV_ITEMS.find((item) => item.urlSection === section) ??
DEFAULT_SCRIPTS_SECTION;
// Redirect to the default section if the URL section is not in the filtered list
if (
section &&
currentFormSection === DEFAULT_SCRIPTS_SECTION &&
section !== DEFAULT_SCRIPTS_SECTION.urlSection
) {
router.replace(DEFAULT_SCRIPTS_SECTION.path);
return null;
}
const CurrentCard = currentFormSection.Card;
return (
@@ -1,7 +1,8 @@
import { useMemo } from "react";
import { useContext, useMemo } from "react";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { ISideNavItem } from "pages/admin/components/SideNav/SideNav";
import ScriptBatchProgress, {
@@ -23,23 +24,29 @@ type IScriptsCardProps = IScriptLibraryProps | IScriptBatchProgressProps;
const useScriptNavItems = (
teamId: number | undefined
): ISideNavItem<IScriptsCardProps>[] => {
return useMemo(
() => [
const { isGlobalTechnician, isTeamTechnician } = useContext(AppContext);
return useMemo(() => {
const items: ISideNavItem<IScriptsCardProps>[] = [
{
title: "Library",
urlSection: "library",
path: `${PATHS.CONTROLS_SCRIPTS_LIBRARY}?team_id=${teamId || 0}`,
Card: ScriptLibrary,
},
{
];
if (!isTeamTechnician && !isGlobalTechnician) {
items.push({
title: "Batch progress",
urlSection: "progress",
path: `${PATHS.CONTROLS_SCRIPTS_BATCH_PROGRESS}?team_id=${teamId || 0}`,
Card: ScriptBatchProgress,
},
],
[teamId]
);
});
}
return items;
}, [teamId, isTeamTechnician, isGlobalTechnician]);
};
export default useScriptNavItems;
@@ -18,6 +18,7 @@ import InfoBanner from "components/InfoBanner";
import Spinner from "components/Spinner";
import Pagination from "components/Pagination";
import SectionHeader from "components/SectionHeader";
import Card from "components/Card";
import UploadList from "../../../components/UploadList";
import DeleteScriptModal from "../../components/DeleteScriptModal";
@@ -40,7 +41,12 @@ const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => {
? parseInt(location.query.page, 10)
: DEFAULT_PAGE;
const { isPremiumTier } = useContext(AppContext);
const { isPremiumTier, isGlobalTechnician, isTeamTechnician } = useContext(
AppContext
);
const isTechnician = isGlobalTechnician || isTeamTechnician;
const [showDeleteScriptModal, setShowDeleteScriptModal] = useState(false);
const [showEditScriptModal, setShowEditScriptModal] = useState(false);
const [showAddScriptModal, setShowAddScriptModal] = useState(false);
@@ -118,10 +124,6 @@ const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => {
refetchScripts();
};
const onUploadScript = () => {
refetchScripts();
};
const renderScriptsList = () => {
if (isLoading) {
return <Spinner />;
@@ -136,7 +138,11 @@ const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => {
}
const headingComponent = () => (
<ScriptListHeading onClickAddScript={() => setShowAddScriptModal(true)} />
<ScriptListHeading
onClickAddScript={
isTechnician ? undefined : () => setShowAddScriptModal(true)
}
/>
);
return (
@@ -151,6 +157,7 @@ const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => {
onDelete={onClickDelete}
onClickScript={onClickScript}
onEdit={onEditScript}
isTechnician={isTechnician}
/>
)}
/>
@@ -181,9 +188,14 @@ const ScriptLibrary = ({ router, teamId, location }: IScriptLibraryProps) => {
<SectionHeader title="Library" alignLeftHeaderVertically />
{config.server_settings.scripts_disabled && renderScriptsDisabledBanner()}
{renderScriptsList()}
{!isLoading && currentPage === 0 && !scripts?.length && (
<ScriptUploader onButtonClick={() => setShowAddScriptModal(true)} />
)}
{!isLoading &&
currentPage === 0 &&
!scripts?.length &&
(isTechnician ? (
<Card className="empty-scripts">No scripts uploaded.</Card>
) : (
<ScriptUploader onButtonClick={() => setShowAddScriptModal(true)} />
))}
{showDeleteScriptModal && selectedScript.current && (
<DeleteScriptModal
scriptName={selectedScript.current?.name}
@@ -1,3 +1,8 @@
.script-library {
@include vertical-card-layout;
.empty-scripts {
font-size: px-to-rem(14);
text-align: center;
}
}
@@ -5,17 +5,15 @@ import classnames from "classnames";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import { getPathWithQueryParams } from "utilities/url";
import RunScriptHelpText from "pages/hosts/components/ScriptDetailsModal/RunScriptHelpText";
import scriptAPI from "services/entities/scripts";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import DataError from "components/DataError";
import Editor from "components/Editor";
import Modal from "components/Modal";
import ModalFooter from "components/ModalFooter";
import Spinner from "components/Spinner";
import paths from "router/paths";
import { ScriptContent } from "interfaces/script";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
@@ -88,7 +86,24 @@ const EditScriptModal = ({
onExit,
}: IEditScriptModal) => {
const { renderFlash } = useContext(NotificationContext);
const { currentTeam } = useContext(AppContext);
const {
currentTeam,
isGlobalAdmin,
isAnyTeamAdmin,
isGlobalMaintainer,
isAnyTeamMaintainer,
isTeamTechnician,
isGlobalTechnician,
} = useContext(AppContext);
const isTechnician = !!isTeamTechnician || !!isGlobalTechnician;
const canRunScripts = !!(
isGlobalAdmin ||
isAnyTeamAdmin ||
isGlobalMaintainer ||
isAnyTeamMaintainer
);
// Editable script content
const [scriptFormData, setScriptFormData] = useState("");
@@ -177,43 +192,31 @@ const EditScriptModal = ({
onChange={onChange}
value={scriptFormData}
/>
<div className="form-field__help-text">
To run this script on a host, go to the{" "}
<CustomLink
text="Hosts"
url={getPathWithQueryParams(paths.MANAGE_HOSTS, {
team_id: currentTeam?.id,
})}
/>{" "}
page and select a host.
<br />
To run the script across multiple hosts, add a policy automation on
the{" "}
<CustomLink
text="Policies"
url={getPathWithQueryParams(paths.MANAGE_POLICIES, {
team_id: currentTeam?.id,
})}
/>{" "}
page.
</div>
<RunScriptHelpText
className="form-field__help-text"
isTechnician={isTechnician}
canRunScripts={canRunScripts}
teamId={currentTeam?.id}
/>
</form>
<ModalFooter
primaryButtons={
<>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
<Button
onClick={onSave}
isLoading={isSubmitting}
disabled={!!formError}
>
Save
</Button>
</>
}
/>
{canRunScripts && (
<ModalFooter
primaryButtons={
<>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
<Button
onClick={onSave}
isLoading={isSubmitting}
disabled={!!formError}
>
Save
</Button>
</>
}
/>
)}
</>
);
};
@@ -6,32 +6,34 @@ import Icon from "components/Icon";
const baseClass = "script-list-heading";
interface IScriptListHeading {
onClickAddScript: () => void;
onClickAddScript?: () => void;
}
const ScriptListHeading = ({ onClickAddScript }: IScriptListHeading) => {
return (
<div className={baseClass}>
<span className={`${baseClass}__heading-title`}>Scripts</span>
<span className={`${baseClass}__heading-actions`}>
<GitOpsModeTooltipWrapper
position="left"
renderChildren={(disableChildren) => (
<Button
disabled={disableChildren}
variant="brand-inverse-icon"
className={`${baseClass}__add-button`}
onClick={onClickAddScript}
iconStroke
>
<>
<Icon name="plus" color="core-fleet-green" />
Add script
</>
</Button>
)}
/>
</span>
{onClickAddScript && (
<span className={`${baseClass}__heading-actions`}>
<GitOpsModeTooltipWrapper
position="left"
renderChildren={(disableChildren) => (
<Button
disabled={disableChildren}
variant="brand-inverse-icon"
className={`${baseClass}__add-button`}
onClick={onClickAddScript}
iconStroke
>
<>
<Icon name="plus" color="core-fleet-green" />
Add script
</>
</Button>
)}
/>
</span>
)}
</div>
);
};
@@ -20,6 +20,7 @@ interface IScriptListItemProps {
onDelete: (script: IScript) => void;
onClickScript: (script: IScript) => void;
onEdit: (script: IScript) => void;
isTechnician?: boolean;
}
// TODO - useful to have a 'platform' field from API, for use elsewhere in app as well?
@@ -79,6 +80,7 @@ const ScriptListItem = ({
onDelete,
onClickScript,
onEdit,
isTechnician,
}: IScriptListItemProps) => {
const { renderFlash } = useContext(NotificationContext);
@@ -146,7 +148,7 @@ const ScriptListItem = ({
createdAt={script.created_at}
/>
}
actions={actions}
actions={isTechnician ? undefined : actions}
onClick={() => onClickScript(script)}
/>
);