mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #40317 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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 - [ ] Added/updated automated tests With the current router we have in place, we can't really test `<Link>` elements, so our ability to make useful automated tests is pretty limited here. I extracted the fleet name sorting code into an exported function and added some tests for that. - [X] QA'd all new/changed functionality manually - [X] verified that when All Fleets is selected in dropdown, navigating to Controls switches to Workstations - [X] verified that when another fleet is selected in dropdown, navigating to Controls maintains that selection - [X] verified that when a fleet is selected in dropdown, navigating to the dashboard changes to All Fleets - [X] verified that when "Unassigned" is present in the fleets dropdown, it is at the bottom - [X] verified that when using a permalink to the dashboard with a fleet selected (e.g. `?fleet_id=1`), the correct fleet shows as selected
526 lines
16 KiB
TypeScript
526 lines
16 KiB
TypeScript
import { useCallback, useContext, useEffect, useMemo } from "react";
|
|
import { InjectedRouter } from "react-router";
|
|
import { findLastIndex, sortBy, trimStart } from "lodash";
|
|
|
|
import { AppContext } from "context/app";
|
|
import { TableContext } from "context/table";
|
|
import {
|
|
API_NO_TEAM_ID,
|
|
API_ALL_TEAMS_ID,
|
|
APP_CONTEXT_ALL_TEAMS_ID,
|
|
APP_CONTEXT_NO_TEAM_ID,
|
|
isAnyTeamSelected,
|
|
ITeamSummary,
|
|
ITeam,
|
|
} from "interfaces/team";
|
|
import { IUser, IUserRole } from "interfaces/user";
|
|
import permissions from "utilities/permissions";
|
|
import sort from "utilities/sort";
|
|
|
|
type OnTeamChangeFuncShouldStripParam = (
|
|
teamIdForApi: number | undefined
|
|
) => boolean;
|
|
|
|
type OnTeamChangeFuncShouldStripParamConsiderCurTeam = (
|
|
newTeamid: number | undefined,
|
|
curTeamId: number | undefined
|
|
) => boolean;
|
|
|
|
type OnTeamChangeFuncShouldReplaceParam = (
|
|
teamIdForApi: number | undefined
|
|
) => [boolean, string];
|
|
|
|
type ChangeTeamOverrideParamFn =
|
|
| OnTeamChangeFuncShouldReplaceParam
|
|
| OnTeamChangeFuncShouldStripParam
|
|
| OnTeamChangeFuncShouldStripParamConsiderCurTeam;
|
|
|
|
const considersCurTeam = (
|
|
fn: ChangeTeamOverrideParamFn
|
|
): fn is OnTeamChangeFuncShouldStripParamConsiderCurTeam => fn.length === 2;
|
|
|
|
/**
|
|
* This type is used to define functions that determine whether a query parameter should be stripped or replaced
|
|
* when the team id changes.
|
|
*
|
|
* The key is the name of the query parameter
|
|
* The value is a function that receives the new team id and optionally the current team id, and returns either:
|
|
* - a boolean indicating whether the query parameter should be stripped, or
|
|
* - a tuple of a boolean and a string, where the boolean indicates whether the query parameter should be replaced
|
|
* and the string is the new value for the query parameter (TODO - support considering curTeamId)
|
|
*/
|
|
export type IConfigOverrideParamsOnTeamChange = Record<
|
|
string,
|
|
ChangeTeamOverrideParamFn
|
|
>;
|
|
|
|
const splitQueryStringParts = (queryString: string) =>
|
|
trimStart(queryString, "?")
|
|
.split("&")
|
|
.filter((p) => p.includes("="));
|
|
|
|
const joinQueryStringParts = (parts: string[]) =>
|
|
parts.length ? `?${parts.join("&")}` : "";
|
|
|
|
const rebuildQueryStringWithTeamId = (
|
|
queryString: string,
|
|
newTeamId: number,
|
|
curTeamId: number | undefined,
|
|
configAdditionalParams?: IConfigOverrideParamsOnTeamChange
|
|
) => {
|
|
const parts = splitQueryStringParts(queryString);
|
|
|
|
// Reset page to 0
|
|
const pageIndex = parts.findIndex((p) => p.startsWith("page="));
|
|
if (pageIndex !== -1) {
|
|
parts.splice(pageIndex, 1, "page=0");
|
|
}
|
|
|
|
// Backward compat: rewrite legacy team_id= to fleet_id=
|
|
const legacyIndex = parts.findIndex((p) => p.startsWith("team_id="));
|
|
if (legacyIndex !== -1) {
|
|
parts.splice(
|
|
legacyIndex,
|
|
1,
|
|
parts[legacyIndex].replace("team_id=", "fleet_id=")
|
|
);
|
|
}
|
|
|
|
const teamIndex = parts.findIndex((p) => p.startsWith("fleet_id="));
|
|
// URLs for the app represent "All teams" by the absence of the fleet id param
|
|
const newTeamPart =
|
|
newTeamId > APP_CONTEXT_ALL_TEAMS_ID ? `fleet_id=${newTeamId}` : "";
|
|
|
|
if (teamIndex === -1) {
|
|
// nothing to remove/replace so add the new part (if any) and rejoin
|
|
return joinQueryStringParts(
|
|
newTeamPart ? parts.concat(newTeamPart) : parts
|
|
);
|
|
}
|
|
|
|
if (teamIndex !== findLastIndex(parts, (p) => p.startsWith("fleet_id="))) {
|
|
console.warn(
|
|
`URL contains more than one fleet_id parameter: ${queryString}`
|
|
);
|
|
}
|
|
|
|
if (newTeamPart) {
|
|
parts.splice(teamIndex, 1, newTeamPart); // remove the old part and replace with the new
|
|
} else {
|
|
parts.splice(teamIndex, 1); // just remove the old team part
|
|
}
|
|
|
|
if (configAdditionalParams) {
|
|
Object.entries(configAdditionalParams).forEach(([paramName, fn]) => {
|
|
let shouldStrip = false;
|
|
let shouldReplace = false;
|
|
let replaceString = "";
|
|
|
|
let val;
|
|
if (considersCurTeam(fn)) {
|
|
val = fn(newTeamId, curTeamId);
|
|
} else {
|
|
val = fn(newTeamId);
|
|
}
|
|
if (Array.isArray(val)) {
|
|
[shouldReplace, replaceString] = val;
|
|
} else if (typeof val === "boolean") {
|
|
shouldStrip = val;
|
|
}
|
|
|
|
if (shouldStrip || shouldReplace) {
|
|
const paramIndex = parts.findIndex((p) =>
|
|
p.startsWith(`${paramName}=`)
|
|
);
|
|
|
|
if (shouldStrip && paramIndex !== -1) {
|
|
parts.splice(paramIndex, 1);
|
|
return;
|
|
}
|
|
|
|
if (shouldReplace) {
|
|
const newPart = `${paramName}=${replaceString}`;
|
|
if (paramIndex === -1) {
|
|
parts.splice(paramIndex, 1, newPart);
|
|
} else {
|
|
parts.push(newPart);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return joinQueryStringParts(parts);
|
|
};
|
|
|
|
const filterUserTeamsByRole = (
|
|
userTeams: ITeam[],
|
|
permittedAccessByUserRole?: Record<IUserRole, boolean>
|
|
) => {
|
|
if (!permittedAccessByUserRole) {
|
|
return userTeams;
|
|
}
|
|
|
|
return userTeams
|
|
.filter(
|
|
({ role }) => role && !!permittedAccessByUserRole[role as IUserRole]
|
|
)
|
|
.sort((a, b) => sort.caseInsensitiveAsc(a.name, b.name));
|
|
};
|
|
|
|
const getUserTeams = ({
|
|
availableTeams,
|
|
currentUser,
|
|
permittedAccessByTeamRole,
|
|
}: {
|
|
availableTeams?: ITeamSummary[];
|
|
currentUser: IUser | null;
|
|
permittedAccessByTeamRole?: Record<IUserRole, boolean>;
|
|
}) => {
|
|
if (!currentUser || !availableTeams?.length) {
|
|
return undefined;
|
|
}
|
|
|
|
return permissions.isOnGlobalTeam(currentUser)
|
|
? availableTeams
|
|
: filterUserTeamsByRole(currentUser.teams, permittedAccessByTeamRole);
|
|
};
|
|
|
|
const getDefaultTeam = ({
|
|
currentUser,
|
|
includeAllTeams,
|
|
includeNoTeam,
|
|
userTeams,
|
|
isPrimoMode,
|
|
}: {
|
|
currentUser: IUser | null;
|
|
includeAllTeams: boolean;
|
|
includeNoTeam: boolean;
|
|
userTeams?: ITeamSummary[];
|
|
isPrimoMode: boolean;
|
|
}) => {
|
|
if (!currentUser || !userTeams?.length) {
|
|
return undefined;
|
|
}
|
|
if (permissions.isOnGlobalTeam(currentUser)) {
|
|
let defaultTeam: ITeamSummary | undefined;
|
|
if (isPrimoMode) {
|
|
// in Primo mode "No team" takes precedence
|
|
if (includeNoTeam) {
|
|
defaultTeam = userTeams.find((t) => t.id === APP_CONTEXT_NO_TEAM_ID);
|
|
} else if (includeAllTeams) {
|
|
defaultTeam = userTeams.find((t) => t.id === APP_CONTEXT_ALL_TEAMS_ID);
|
|
} else {
|
|
// neither All teams nor No team included on the page, as is the case for a few settings
|
|
// pages. Default to "All teams"
|
|
defaultTeam = userTeams.find((t) => t.id === APP_CONTEXT_ALL_TEAMS_ID);
|
|
}
|
|
} else {
|
|
// normally "All teams" takes precedence
|
|
if (includeAllTeams) {
|
|
defaultTeam = userTeams.find((t) => t.id === APP_CONTEXT_ALL_TEAMS_ID);
|
|
}
|
|
if (!defaultTeam && includeNoTeam) {
|
|
// prefer the real fleet with the lowest ID over "Unassigned"
|
|
const realFleets = userTeams.filter(
|
|
(t) => t.id > APP_CONTEXT_NO_TEAM_ID
|
|
);
|
|
if (realFleets.length > 0) {
|
|
defaultTeam = sortBy(realFleets, (t) => t.id)[0];
|
|
} else {
|
|
defaultTeam = userTeams.find((t) => t.id === APP_CONTEXT_NO_TEAM_ID);
|
|
}
|
|
}
|
|
}
|
|
|
|
return defaultTeam || userTeams.find((t) => t.id > APP_CONTEXT_NO_TEAM_ID);
|
|
}
|
|
|
|
return (
|
|
userTeams.find((t) => permissions.isTeamAdmin(currentUser, t.id)) ||
|
|
userTeams.find((t) => permissions.isTeamMaintainer(currentUser, t.id)) ||
|
|
userTeams.find((t) => t.id > APP_CONTEXT_NO_TEAM_ID)
|
|
);
|
|
};
|
|
|
|
const getTeamIdForApi = ({
|
|
currentTeam,
|
|
includeAllTeams = true,
|
|
includeNoTeam = false,
|
|
}: {
|
|
currentTeam?: ITeamSummary;
|
|
includeAllTeams?: boolean;
|
|
includeNoTeam?: boolean;
|
|
}) => {
|
|
// note that CONTEXT refers to React Context, not the colloquial use of the word context, so the
|
|
// return value of this function should be used everywhere except in Context, not just in calls to
|
|
// the API service
|
|
if (includeNoTeam && currentTeam?.id === APP_CONTEXT_NO_TEAM_ID) {
|
|
return API_NO_TEAM_ID;
|
|
}
|
|
if (includeAllTeams && currentTeam?.id === APP_CONTEXT_ALL_TEAMS_ID) {
|
|
return API_ALL_TEAMS_ID;
|
|
}
|
|
if (currentTeam && currentTeam.id > APP_CONTEXT_NO_TEAM_ID) {
|
|
return currentTeam.id;
|
|
}
|
|
// should never reach this case
|
|
return undefined;
|
|
};
|
|
|
|
const isValidTeamId = ({
|
|
userTeams,
|
|
includeAllTeams,
|
|
includeNoTeam,
|
|
teamId,
|
|
isPrimoMode,
|
|
}: {
|
|
userTeams: ITeamSummary[];
|
|
includeAllTeams: boolean;
|
|
includeNoTeam: boolean;
|
|
teamId: number;
|
|
isPrimoMode: boolean;
|
|
}) => {
|
|
if (isPrimoMode) {
|
|
// teamId at this point for all teams will be coerced to -1
|
|
if (includeNoTeam) {
|
|
return teamId === APP_CONTEXT_NO_TEAM_ID;
|
|
}
|
|
if (includeAllTeams) {
|
|
return teamId === APP_CONTEXT_ALL_TEAMS_ID;
|
|
}
|
|
// neither included - this is the case in a number of settings pages. Consider valid to allow
|
|
// editing teams
|
|
return true;
|
|
}
|
|
if (
|
|
(teamId === APP_CONTEXT_ALL_TEAMS_ID && !includeAllTeams) ||
|
|
(teamId === APP_CONTEXT_NO_TEAM_ID && !includeNoTeam) ||
|
|
!userTeams?.find((t) => t.id === teamId)
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const coerceAllTeamsId = (s?: string) => {
|
|
// URLs for the app represent "All teams" by the absence of the team id param
|
|
// "All teams" is represented in AppContext with -1 as the team id so empty
|
|
// strings are coerced to -1 by this function
|
|
return s?.length ? parseInt(s, 10) : APP_CONTEXT_ALL_TEAMS_ID;
|
|
};
|
|
|
|
const shouldRedirectToDefaultTeam = ({
|
|
userTeams,
|
|
includeAllTeams,
|
|
includeNoTeam,
|
|
query,
|
|
isPrimoMode,
|
|
}: {
|
|
userTeams: ITeamSummary[];
|
|
includeAllTeams: boolean;
|
|
includeNoTeam: boolean;
|
|
query: { fleet_id?: string };
|
|
isPrimoMode: boolean;
|
|
}) => {
|
|
const teamIdString = query?.fleet_id || "";
|
|
const parsedTeamId = parseInt(teamIdString, 10);
|
|
|
|
// redirect non-numeric strings and negative numbers to default (e.g., `/hosts?fleet_id=-1` should
|
|
// be redirected to `/hosts`)
|
|
if (teamIdString.length && (isNaN(parsedTeamId) || parsedTeamId < 0)) {
|
|
return true;
|
|
}
|
|
|
|
// coerce empty string to -1 (i.e. `ALL_TEAMS_ID`) and test again (this ensures that non-global users will be
|
|
// redirected to their default team when they attempt to access the `/hosts` page and also ensures
|
|
// all users are redirected to their default when they attempt to acess non-existent team ids).
|
|
return !isValidTeamId({
|
|
userTeams,
|
|
includeAllTeams,
|
|
includeNoTeam,
|
|
teamId: coerceAllTeamsId(teamIdString),
|
|
isPrimoMode,
|
|
});
|
|
};
|
|
|
|
export const useTeamIdParam = ({
|
|
location = { pathname: "", search: "", hash: "", query: {} },
|
|
router,
|
|
includeAllTeams,
|
|
includeNoTeam,
|
|
permittedAccessByTeamRole,
|
|
resetSelectedRowsOnTeamChange = true,
|
|
overrideParamsOnTeamChange,
|
|
}: {
|
|
location?: {
|
|
pathname: string;
|
|
search: string;
|
|
query: { fleet_id?: string; team_id?: string };
|
|
hash?: string;
|
|
[key: string]: any; // for other location properties that may be passed in
|
|
};
|
|
router: InjectedRouter;
|
|
includeAllTeams: boolean;
|
|
includeNoTeam: boolean;
|
|
permittedAccessByTeamRole?: Record<IUserRole, boolean>;
|
|
resetSelectedRowsOnTeamChange?: boolean;
|
|
overrideParamsOnTeamChange?: IConfigOverrideParamsOnTeamChange;
|
|
}) => {
|
|
const { hash, pathname, query, search } = location;
|
|
|
|
const hasLegacyTeamIdParam = search.includes("team_id=");
|
|
|
|
const {
|
|
availableTeams,
|
|
currentTeam: contextTeam,
|
|
currentUser,
|
|
isFreeTier,
|
|
isPremiumTier,
|
|
setCurrentTeam: setContextTeam,
|
|
config,
|
|
} = useContext(AppContext);
|
|
const isPrimoMode = config?.partnerships?.enable_primo || false;
|
|
|
|
const { setResetSelectedRows } = useContext(TableContext);
|
|
|
|
const userTeams = useMemo(
|
|
() =>
|
|
getUserTeams({ currentUser, availableTeams, permittedAccessByTeamRole }),
|
|
[availableTeams, currentUser, permittedAccessByTeamRole]
|
|
);
|
|
|
|
const defaultTeam = useMemo(
|
|
() =>
|
|
getDefaultTeam({
|
|
currentUser,
|
|
includeAllTeams,
|
|
includeNoTeam,
|
|
userTeams,
|
|
isPrimoMode,
|
|
}),
|
|
[currentUser, includeAllTeams, includeNoTeam, isPrimoMode, userTeams]
|
|
);
|
|
|
|
const currentTeam = useMemo(
|
|
() =>
|
|
userTeams?.find(
|
|
(t) =>
|
|
t.id === coerceAllTeamsId(query?.fleet_id || query?.team_id || "")
|
|
),
|
|
[query?.fleet_id, query?.team_id, userTeams]
|
|
);
|
|
|
|
const handleTeamChange = useCallback(
|
|
(newTeamId: number) => {
|
|
// TODO: This results in a warning that TableProvider is being updated while
|
|
// rendering a different component (the component that invokes the useTeamIdParam hook).
|
|
// This requires further investigation but is not currently causing any known issues.
|
|
if (resetSelectedRowsOnTeamChange) {
|
|
setResetSelectedRows(true);
|
|
}
|
|
|
|
// `replace` instead of `push` is okay here since we don't want users to be able to go back to
|
|
// the invalid route we are replacing
|
|
router.replace(
|
|
pathname
|
|
.concat(
|
|
rebuildQueryStringWithTeamId(
|
|
search,
|
|
newTeamId,
|
|
currentTeam?.id,
|
|
overrideParamsOnTeamChange
|
|
)
|
|
)
|
|
.concat(hash || "")
|
|
);
|
|
},
|
|
[
|
|
resetSelectedRowsOnTeamChange,
|
|
router,
|
|
pathname,
|
|
search,
|
|
currentTeam?.id,
|
|
overrideParamsOnTeamChange,
|
|
hash,
|
|
setResetSelectedRows,
|
|
]
|
|
);
|
|
|
|
// reconcile router location and redirect to default team as applicable
|
|
let isRouteOk = false;
|
|
if (hasLegacyTeamIdParam) {
|
|
// Backward compat: redirect legacy ?team_id= URLs to ?fleet_id=
|
|
// Skip other reconciliation to avoid a second redirect overwriting this one.
|
|
router.replace(
|
|
pathname
|
|
.concat(search.replace(/\bteam_id=/g, "fleet_id="))
|
|
.concat(hash || "")
|
|
);
|
|
} else if (isFreeTier) {
|
|
// free tier should never have fleet_id param, so change to "All teams"
|
|
if (query.fleet_id) {
|
|
handleTeamChange(-1); // -1 because all pages on Free actually function as if on "All teams", even when not supported e.g. Controls
|
|
} else {
|
|
isRouteOk = true;
|
|
}
|
|
} else if (isPremiumTier && userTeams?.length && defaultTeam) {
|
|
if (
|
|
shouldRedirectToDefaultTeam({
|
|
includeAllTeams,
|
|
includeNoTeam,
|
|
query,
|
|
userTeams,
|
|
isPrimoMode,
|
|
})
|
|
) {
|
|
handleTeamChange(defaultTeam.id);
|
|
} else {
|
|
isRouteOk = true;
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (isRouteOk && currentTeam?.id !== contextTeam?.id) {
|
|
setContextTeam(currentTeam);
|
|
}
|
|
}, [contextTeam?.id, currentTeam, isRouteOk, setContextTeam]);
|
|
|
|
return {
|
|
// essentially `currentTeamIdForAppContext`, where -1 represents all teams, 0 represents no
|
|
// team, and positive integers represent all teams other than the "no team" team
|
|
currentTeamId: currentTeam?.id,
|
|
currentTeamName: currentTeam?.name,
|
|
currentTeamSummary: currentTeam,
|
|
// not including the 'No team' "team", whose id of 0 is falsey
|
|
isAnyTeamSelected: isAnyTeamSelected(currentTeam?.id),
|
|
isAllTeamsSelected:
|
|
!isAnyTeamSelected(currentTeam?.id) && currentTeam?.id !== 0,
|
|
/** isRouteOk indicates whether the team currently indicated by the url params is valid for the
|
|
* current user and tier */
|
|
isRouteOk,
|
|
isTeamAdmin:
|
|
!!currentTeam?.id && permissions.isTeamAdmin(currentUser, currentTeam.id),
|
|
isTeamMaintainer:
|
|
!!currentTeam?.id &&
|
|
permissions.isTeamMaintainer(currentUser, currentTeam.id),
|
|
isTeamTechnician:
|
|
!!currentTeam?.id &&
|
|
permissions.isTeamTechnician(currentUser, currentTeam.id),
|
|
isTeamMaintainerOrTeamAdmin:
|
|
!!currentTeam?.id &&
|
|
permissions.isTeamMaintainerOrTeamAdmin(currentUser, currentTeam.id),
|
|
isTeamObserver:
|
|
!!currentTeam?.id &&
|
|
permissions.isTeamObserver(currentUser, currentTeam.id),
|
|
isObserverPlus:
|
|
!!currentTeam?.id &&
|
|
!!currentUser &&
|
|
permissions.isObserverPlus(currentUser, currentTeam.id),
|
|
teamIdForApi: getTeamIdForApi({ currentTeam, includeNoTeam }), // for everywhere except AppContext: fleet_id=0 for No team (same as currentTeamId), undefined for All teams
|
|
userTeams,
|
|
handleTeamChange,
|
|
};
|
|
};
|
|
|
|
export default useTeamIdParam;
|