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:
co-authored by
Lucas Manuel Rodriguez
parent
19cdbf940d
commit
ffe0f71c83
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+43
-40
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+22
-20
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-1
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user