Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ee5a684
Initial changes for Technician role
lucasmrod Feb 2, 2026
416aaf3
Technician role FE changes
nulmete Feb 6, 2026
937bbe5
Add more capabilities to technician
lucasmrod Feb 6, 2026
2373975
tweak technician role checks
nulmete Feb 9, 2026
73f4f9c
more technician conditional check tweaks
nulmete Feb 9, 2026
4a4d3bd
Merge branch '38621-add-technician-role' into nulmete/technician-role-fe
nulmete Feb 9, 2026
7971759
Add comments
lucasmrod Feb 9, 2026
b48ec0a
Add missing script execution permissions
lucasmrod Feb 9, 2026
fd1b66f
Merge branch '38621-add-technician-role' into nulmete/technician-role-fe
nulmete Feb 9, 2026
0f16f6f
Prevent use of new role in Fleet Free
lucasmrod Feb 9, 2026
a63df17
Add changes
lucasmrod Feb 9, 2026
41b3c5f
tweak policy, software library and labels permissions
nulmete Feb 9, 2026
5af7b1a
Merge branch 'main' into 38621-add-technician-role
lucasmrod Feb 9, 2026
e8b2c2d
add AuthAnyMaintainerAdminTechnicianRoutes
nulmete Feb 9, 2026
70c18d5
Merge branch '38621-add-technician-role' into nulmete/technician-role-fe
nulmete Feb 9, 2026
7dc0596
Fix tests
lucasmrod Feb 9, 2026
a8e260d
empty state styles, more tweaks to permissions
nulmete Feb 10, 2026
cb08dbf
Merge branch '38621-add-technician-role' into nulmete/technician-role-fe
nulmete Feb 10, 2026
0e74a1e
Add missing permissions
lucasmrod Feb 10, 2026
642b8f0
restrict routes
nulmete Feb 10, 2026
3226996
Merge branch '38621-add-technician-role' into nulmete/technician-role-fe
nulmete Feb 10, 2026
ce0e978
allow technician to run live query
nulmete Feb 10, 2026
8ac7713
render software installer card for team technician
nulmete Feb 10, 2026
0f902b0
distinguish permissions to add software and install/uninstall software
nulmete Feb 10, 2026
fe1111c
don't allow selection on Hosts page, tweak copy for script details modal
nulmete Feb 11, 2026
3b7e646
Merge branch 'main' into nulmete/technician-role-fe
nulmete Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ const SiteTopNav = ({
isGlobalAdmin,
isGlobalMaintainer,
isAnyTeamMaintainer,
isGlobalTechnician,
isAnyTeamTechnician,
isNoAccess,
} = useContext(AppContext);

Expand Down Expand Up @@ -231,7 +233,9 @@ const SiteTopNav = ({
isAnyTeamAdmin,
isAnyTeamMaintainer,
isGlobalMaintainer,
isNoAccess
isNoAccess,
isGlobalTechnician,
isAnyTeamTechnician
);

const renderNavItems = () => {
Expand Down
19 changes: 11 additions & 8 deletions frontend/components/top_nav/SiteTopNav/navItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,14 @@ export default (
isAnyTeamAdmin = false,
isAnyTeamMaintainer = false,
isGlobalMaintainer = false,
isNoAccess = false
isNoAccess = false,
isGlobalTechnician = false,
isAnyTeamTechnician = false
): INavItem[] => {
if (!user) {
return [];
}

const isMaintainerOrAdmin =
isGlobalMaintainer ||
isAnyTeamMaintainer ||
isGlobalAdmin ||
isAnyTeamAdmin;

const logo = [
{
icon: "logo",
Expand Down Expand Up @@ -66,7 +62,14 @@ export default (
regex: new RegExp(`^${URL_PREFIX}/controls/`),
pathname: PATHS.CONTROLS,
},
exclude: !isMaintainerOrAdmin,
exclude: !(
isGlobalMaintainer ||
isAnyTeamMaintainer ||
isGlobalAdmin ||
isAnyTeamAdmin ||
isGlobalTechnician ||
isAnyTeamTechnician
),
alwaysToPathname: true,
withParams: { type: "query", names: ["team_id"] },
},
Expand Down
15 changes: 15 additions & 0 deletions frontend/context/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ type InitialStateType = {
isTeamMaintainerOrTeamAdmin?: boolean;
isAnyTeamAdmin?: boolean;
isTeamAdmin?: boolean;
isTeamTechnician?: boolean;
isAnyTeamTechnician?: boolean;
isGlobalTechnician?: boolean;
isOnlyObserver?: boolean;
isObserverPlus?: boolean;
isNoAccess?: boolean;
Expand Down Expand Up @@ -235,6 +238,9 @@ export const initialState = {
isTeamMaintainerOrTeamAdmin: undefined,
isAnyTeamAdmin: undefined,
isTeamAdmin: undefined,
isTeamTechnician: undefined,
isAnyTeamTechnician: undefined,
isGlobalTechnician: undefined,
isOnlyObserver: undefined,
isObserverPlus: undefined,
isNoAccess: undefined,
Expand Down Expand Up @@ -304,6 +310,7 @@ const setPermissions = (
isGlobalAdmin: permissions.isGlobalAdmin(user),
isGlobalMaintainer: permissions.isGlobalMaintainer(user),
isGlobalObserver: permissions.isGlobalObserver(user),
isGlobalTechnician: permissions.isGlobalTechnician(user),
isOnGlobalTeam: permissions.isOnGlobalTeam(user),
isAnyTeamObserverPlus: permissions.isAnyTeamObserverPlus(user),
isAnyTeamMaintainer: permissions.isAnyTeamMaintainer(user),
Expand All @@ -314,6 +321,8 @@ const setPermissions = (
isTeamObserver: permissions.isTeamObserver(user, teamId),
isTeamMaintainer: permissions.isTeamMaintainer(user, teamId),
isTeamAdmin: permissions.isTeamAdmin(user, teamId),
isTeamTechnician: permissions.isTeamTechnician(user, teamId),
isAnyTeamTechnician: permissions.isAnyTeamTechnician(user),
isTeamMaintainerOrTeamAdmin: permissions.isTeamMaintainerOrTeamAdmin(
user,
teamId
Expand Down Expand Up @@ -519,6 +528,9 @@ const AppProvider = ({ children }: Props): JSX.Element => {
isTeamObserver: state.isTeamObserver,
isTeamMaintainer: state.isTeamMaintainer,
isTeamAdmin: state.isTeamAdmin,
isTeamTechnician: state.isTeamTechnician,
isAnyTeamTechnician: state.isAnyTeamTechnician,
isGlobalTechnician: state.isGlobalTechnician,
isTeamMaintainerOrTeamAdmin: state.isTeamMaintainerOrTeamAdmin,
isAnyTeamAdmin: state.isAnyTeamAdmin,
isOnlyObserver: state.isOnlyObserver,
Expand Down Expand Up @@ -637,6 +649,9 @@ const AppProvider = ({ children }: Props): JSX.Element => {
state.isPremiumTier,
state.isSandboxMode,
state.isTeamAdmin,
state.isTeamTechnician,
state.isAnyTeamTechnician,
state.isGlobalTechnician,
state.isTeamMaintainer,
state.isTeamMaintainerOrTeamAdmin,
state.isTeamObserver,
Expand Down
3 changes: 3 additions & 0 deletions frontend/hooks/useTeamIdParam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,9 @@ export const useTeamIdParam = ({
isTeamMaintainer:
!!currentTeam?.id &&
permissions.isTeamMaintainer(currentUser, currentTeam.id),
isTeamTechnician:
!!currentTeam?.id &&
permissions.isTeamTechnician(currentUser, currentTeam.id),
isTeamMaintainerOrTeamAdmin:
!!currentTeam?.id &&
permissions.isTeamMaintainerOrTeamAdmin(currentUser, currentTeam.id),
Expand Down
32 changes: 28 additions & 4 deletions frontend/pages/ManageControlsPage/ManageControlsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useContext, useMemo } from "react";
import React, { useCallback, useContext, useEffect, useMemo } from "react";
import { Tab, Tabs, TabList } from "react-tabs";
import { InjectedRouter } from "react-router";

Expand Down Expand Up @@ -92,6 +92,8 @@ const ManageControlsPage = ({
isPremiumTier,
isGlobalAdmin,
isTeamAdmin,
isTeamTechnician,
isGlobalTechnician,
} = useContext(AppContext);

const {
Expand All @@ -109,19 +111,41 @@ const ManageControlsPage = ({
maintainer: true,
observer: false,
observer_plus: false,
technician: false,
technician: true,
},
});

const permittedControlsSubNav = useMemo(() => {
let renderedSubNav = controlsSubNav;
if (!isGlobalAdmin && !isTeamAdmin) {
if (isTeamTechnician || isGlobalTechnician) {
renderedSubNav = controlsSubNav.filter((navItem) => {
return navItem.name === "OS settings" || navItem.name === "Scripts";
});
} else if (!isGlobalAdmin && !isTeamAdmin) {
renderedSubNav = controlsSubNav.filter((navItem) => {
return navItem.name !== "OS updates";
});
}
return renderedSubNav;
}, [isGlobalAdmin, isTeamAdmin]);
}, [isGlobalAdmin, isTeamAdmin, isTeamTechnician, isGlobalTechnician]);

// Redirect to the first permitted tab if the current path doesn't match any
const currentTabIndex = getTabIndex(
permittedControlsSubNav,
location?.pathname || ""
);
useEffect(() => {
if (currentTabIndex === -1 && permittedControlsSubNav.length > 0) {
const newParams = new URLSearchParams(location?.search);
subNavQueryParams.forEach((p) => newParams.delete(p));
const newQuery = newParams.toString();
router.replace(
permittedControlsSubNav[0].pathname.concat(
newQuery ? `?${newQuery}` : ""
)
);
}
}, [currentTabIndex, permittedControlsSubNav, location?.search, router]);
Comment on lines +132 to +148
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to one of the comments above.

We hide Certificates tab to Technicians, but I was still able to navigate to https://localhost:8080/controls/os-settings/certificates?team_id=0. This didn't render the Certificates content, but the Disk encryption content. However, the URL didn't reflect that, and this logic fixes it.


const navigateToNav = useCallback(
(i: number): void => {
Expand Down
31 changes: 26 additions & 5 deletions frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useContext } from "react";
import React, { useContext, useMemo } from "react";
import { InjectedRouter, Params } from "react-router/lib/Router";
import { useQuery } from "react-query";

Expand Down Expand Up @@ -28,7 +28,9 @@ const OSSettings = ({
params,
}: IOSSettingsProps) => {
const { section } = params;
const { currentTeam } = useContext(AppContext);
const { currentTeam, isTeamTechnician, isGlobalTechnician } = useContext(
AppContext
);

// TODO: consider using useTeamIdParam hook here instead in the future
const teamId =
Expand All @@ -50,12 +52,31 @@ const OSSettings = ({
}
);

const DEFAULT_SETTINGS_SECTION = OS_SETTINGS_NAV_ITEMS[0];
const filteredNavItems = useMemo(() => {
if (isTeamTechnician || isGlobalTechnician) {
return OS_SETTINGS_NAV_ITEMS.filter(
(item) => item.title !== "Certificates"
);
}
return OS_SETTINGS_NAV_ITEMS;
}, [isTeamTechnician, isGlobalTechnician]);
Comment on lines +55 to +62
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technicians can only see Disk encryption and Custom settings.


const DEFAULT_SETTINGS_SECTION = filteredNavItems[0];

const currentFormSection =
OS_SETTINGS_NAV_ITEMS.find((item) => item.urlSection === section) ??
filteredNavItems.find((item) => item.urlSection === section) ??
DEFAULT_SETTINGS_SECTION;

// Redirect to the default section if the URL section is not in the filtered list
if (
section &&
currentFormSection === DEFAULT_SETTINGS_SECTION &&
section !== DEFAULT_SETTINGS_SECTION.urlSection
) {
router.replace(DEFAULT_SETTINGS_SECTION.path.concat(queryString));
return null;
}

const CurrentCard = currentFormSection.Card;

return (
Expand All @@ -71,7 +92,7 @@ const OSSettings = ({
/>
<SideNav
className={`${baseClass}__side-nav`}
navItems={OS_SETTINGS_NAV_ITEMS.map((navItem) => ({
navItems={filteredNavItems.map((navItem) => ({
...navItem,
path: navItem.path.concat(queryString),
}))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { IMdmProfile } from "interfaces/mdm";

import mdmAPI, { IMdmProfilesResponse } from "services/entities/mdm";

import Card from "components/Card/Card";
import CustomLink from "components/CustomLink";
import SectionHeader from "components/SectionHeader";
import PageDescription from "components/PageDescription";
Expand Down Expand Up @@ -46,7 +47,14 @@ const CustomSettings = ({
onMutation,
}: ICustomSettingsProps) => {
const { renderFlash } = useContext(NotificationContext);
const { config, isPremiumTier } = useContext(AppContext);
const {
config,
isPremiumTier,
isGlobalTechnician,
isTeamTechnician,
} = useContext(AppContext);

const isTechnician = isGlobalTechnician || isTeamTechnician;

const mdmEnabled =
config?.mdm.enabled_and_configured ||
Expand Down Expand Up @@ -163,6 +171,13 @@ const CustomSettings = ({
}

if (!profiles?.length) {
if (isTechnician) {
return (
<Card className="empty-profiles">
No configuration profiles have been added.
</Card>
);
}
return <AddProfileCard setShowModal={setShowAddProfileModal} />;
}

Expand All @@ -173,7 +188,9 @@ const CustomSettings = ({
listItems={profiles}
HeadingComponent={() => (
<UploadListHeading
onClickAdd={() => setShowAddProfileModal(true)}
onClickAdd={
isTechnician ? undefined : () => setShowAddProfileModal(true)
}
Comment on lines +191 to +193
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

entityName="Configuration profile"
createEntityText="Add profile"
/>
Expand All @@ -185,6 +202,7 @@ const CustomSettings = ({
setProfileLabelsModalData={setProfileLabelsModalData}
onClickInfo={onClickInfo}
onClickDelete={onClickDelete}
isTechnician={isTechnician}
/>
)}
/>
Expand Down Expand Up @@ -213,11 +231,13 @@ const CustomSettings = ({
variant="right-panel"
content={
<>
Create and upload configuration profiles to apply custom settings.{" "}
{isTechnician
? "View configuration profiles that apply custom settings."
: "Create and upload configuration profiles to apply custom settings."}{" "}
<CustomLink
newTab
text="Learn how"
url="https://fleetdm.com/learn-more-about/custom-os-settings"
text="Learn more"
url="https://fleetdm.com/guides/custom-os-settings"
/>
</>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
.custom-settings {
@include vertical-card-layout;

.empty-profiles {
font-size: px-to-rem(14);
text-align: center;
}

.upload-list {
&__list {
.list-item__label-count {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ interface IProfileListItemProps {
setProfileLabelsModalData: React.Dispatch<
React.SetStateAction<IMdmProfile | null>
>;
isTechnician?: boolean;
}

const ProfileListItem = ({
Expand All @@ -103,6 +104,7 @@ const ProfileListItem = ({
onClickInfo,
onClickDelete,
setProfileLabelsModalData,
isTechnician,
}: IProfileListItemProps) => {
const {
updated_at,
Expand Down Expand Up @@ -180,18 +182,20 @@ const ProfileListItem = ({
>
<Icon name="download" />
</Button>
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<Button
disabled={disableChildren}
className={`${subClass}__action-button`}
variant="icon"
onClick={() => onClickDelete(profile)}
>
<Icon name="trash" />
</Button>
)}
/>
{!isTechnician && (
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<Button
disabled={disableChildren}
className={`${subClass}__action-button`}
variant="icon"
onClick={() => onClickDelete(profile)}
>
<Icon name="trash" />
</Button>
)}
/>
)}
Comment on lines +185 to +198
Copy link
Copy Markdown
Member Author

@nulmete nulmete Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technicians can't delete a configuration profile. See https://www.figma.com/design/4FbBd8bVQ20fHyiUNwMqry/-35696-Add-a-new-role-for-helpdesk?node-id=5318-462&t=Dl8CHuqud0kdhYGZ-0

Might rename isTechnician to something more generic so that we don't couple this component to specific roles.

</div>
</div>
</div>
Expand Down
Loading
Loading