implement member page for team details (#685)

* added reducers and kolide api teams code, hooked up empty state

* request for get all teams and remove unused loading bar

* added create team functionality|gs

* update link cell to be more generic

* create teams detail page and hook it up

* added tabbing and styling to top nav team details

* added edit and delete modal functionality

* add in table and modals for members for teams

* created reusable edit user modal and use it in manage teams page

* creating add member autocomplete

* hook up adding members to teams

* hook up real members from api into table, and empty state for table

* fix proptype warning

* hooked up table querying for member page

* added remove member modal

* added tems to edit useres on member page

* finish remove member from team

* fixed up editing on members page

* fix the role value in member table

* fix prettier errors

* fixes from PR comments round 1

* add missing error handler on add member

* add dynamic team name to member page and user dynamic user and team names to succuess and errors

* add test for userManagementHelper module

* fix lint errors

* fix tests

* add member test to row results on member page
This commit is contained in:
Gabe Hernandez 2021-04-29 14:47:33 +01:00 committed by GitHub
parent 7490878029
commit a85476c23b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 1474 additions and 118 deletions

View file

@ -0,0 +1 @@
export { default } from "./HeaderCell";

View file

@ -2,41 +2,26 @@ import React from "react";
import { useDispatch } from "react-redux";
import { push } from "react-router-redux";
import { IHost } from "interfaces/host";
import helpers from "kolide/helpers";
import PATHS from "router/paths";
import Button from "components/buttons/Button/Button";
interface ILinkCellProps {
interface ILinkCellProps<T> {
value: string;
host: IHost;
path: string;
title?: string;
}
const LinkCell = (props: ILinkCellProps): JSX.Element => {
const { value, host } = props;
const LinkCell = (props: ILinkCellProps<any>): JSX.Element => {
const { value, path, title } = props;
const dispatch = useDispatch();
const onHostClick = (selectedHost: IHost): void => {
dispatch(push(PATHS.HOST_DETAILS(selectedHost)));
};
const lastSeenTime = (status: string, seenTime: string): string => {
const { humanHostLastSeen } = helpers;
if (status !== "online") {
return `Last Seen: ${humanHostLastSeen(seenTime)} UTC`;
}
return "Online";
const onClick = (): void => {
dispatch(push(path));
};
return (
<Button
onClick={() => onHostClick(host)}
variant="text-link"
title={lastSeenTime(host.status, host.seen_time)}
>
<Button onClick={onClick} variant="text-link" title={title}>
{value}
</Button>
);

View file

@ -0,0 +1 @@
export { default } from "./LinkCell";

View file

@ -0,0 +1 @@
export { default } from "./StatusCell";

View file

@ -0,0 +1 @@
export { default } from "./TextCell";

View file

@ -36,7 +36,7 @@ $base-class: "button";
align-items: center;
padding: $pad-small $pad-medium;
border-radius: $border-radius;
font-size: $small;
font-size: $x-small;
font-family: "Nunito Sans", sans-serif;
font-weight: $bold;
display: inline-flex;

View file

@ -0,0 +1,94 @@
import React from "react";
import { Async, OnChangeHandler, Option } from "react-select";
import classnames from "classnames";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import local from "utilities/local";
// @ts-ignore
import debounce from "utilities/debounce";
const baseClass = "autocomplete-dropdown";
interface IAutocompleteDropdown {
id: string;
placeholder: string;
onChange: OnChangeHandler;
resourceUrl: string;
valueKey: string;
labelKey: string;
value: Option[];
disabled?: boolean;
optionComponent?: JSX.Element;
className?: string;
}
const debounceOptions = {
timeout: 300,
leading: false,
trailing: true,
};
const createUrl = (baseUrl: string, input: string) => {
return `/api${baseUrl}?query=${input}`;
};
const AutocompleteDropdown = (props: IAutocompleteDropdown): JSX.Element => {
const {
className,
disabled,
placeholder,
onChange,
id,
resourceUrl,
valueKey,
labelKey,
value,
} = props;
const wrapperClass = classnames(baseClass, className);
const getOptions = debounce((input: string) => {
if (!input) {
return Promise.resolve({ options: [] });
}
return fetch(createUrl(resourceUrl, input), {
headers: {
authorization: `Bearer ${local.getItem("auth_token")}`,
},
})
.then((res) => {
return res.json();
})
.then((json) => {
return { options: json.users };
})
.catch((err) => {
console.log("There was an error", err);
});
}, debounceOptions);
return (
<div className={wrapperClass}>
<Async
noResultsText={"Nothing found"}
autoload={false}
cache={false}
id={id}
loadOptions={getOptions}
disabled={disabled}
placeholder={placeholder}
onChange={onChange}
valueKey={valueKey}
value={value}
labelKey={labelKey}
filterOptions={(options) => options}
multi
searchable
/>
</div>
);
};
export default AutocompleteDropdown;

View file

@ -0,0 +1 @@
export { default } from "./AutocompleteDropdown";

View file

@ -1,19 +1,20 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import classnames from "classnames";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import KolideIcon from "components/icons/KolideIcon";
const baseClass = "modal";
class Modal extends Component {
static propTypes = {
children: PropTypes.node,
className: PropTypes.string,
onExit: PropTypes.func,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
};
interface IModalProps {
children: JSX.Element;
onExit: () => void;
title: string | JSX.Element;
className?: string;
}
class Modal extends Component<IModalProps> {
render() {
const { children, className, onExit, title } = this.props;
const modalContainerClassName = classnames(

View file

@ -19,5 +19,6 @@ export interface IInvite {
invited_by: number;
name: string;
teams: ITeam[];
sso_enabled: boolean;
global_role: string | null;
}

View file

@ -8,10 +8,34 @@ export default PropTypes.shape({
role: PropTypes.string,
});
/**
* The shape of a team entity
*/
export interface ITeam {
description: string;
name: string;
id: number;
hosts: number;
members: number;
host_count: number;
user_count: number;
// role value is included when the team is in the context of a user.
role?: string;
}
/**
* The shape of a new member to add to a team
*/
interface INewMember {
id: number;
role: string;
}
/**
* The shape of the body expected from the API when adding new members to teams
*/
export interface INewMembersBody {
users: INewMember[];
}
export interface IRemoveMembersBody {
users: { id: number }[];
}

View file

@ -2,28 +2,36 @@ import PropTypes from "prop-types";
import teamInterface, { ITeam } from "./team";
export default PropTypes.shape({
admin: PropTypes.bool,
email: PropTypes.string,
enabled: PropTypes.bool,
force_password_reset: PropTypes.bool,
gravatarURL: PropTypes.string,
global_role: PropTypes.string,
gravatar_url: PropTypes.string,
id: PropTypes.number,
name: PropTypes.string,
position: PropTypes.string,
username: PropTypes.string,
sso_enabled: PropTypes.bool,
teams: PropTypes.arrayOf(teamInterface),
username: PropTypes.string,
});
export interface IUser {
admin: boolean;
email: string;
enabled: boolean;
force_password_reset: boolean;
gravatarURL: string;
global_role: string | null;
gravatar_url: string;
id: number;
name: string;
position: string;
username: string;
sso_enabled: boolean;
teams: ITeam[];
global_role: string | null;
username: string;
}
/**
* The shape of the request body when updating a user.
*/
export interface IUserUpdateBody {
global_role?: string | null;
teams?: ITeam[];
name?: string;
email?: string;
sso_enabled?: boolean;
}

View file

@ -21,9 +21,13 @@ class Base {
return Base._request(GET, endpoint, {}, overrideHeaders);
}
static _deleteRequest(endpoint, headers) {
static _deleteRequest(endpoint, headers, body) {
const { DELETE: method } = Request.REQUEST_METHODS;
const request = new Request({ endpoint, method, headers });
const requestAttrs =
body === undefined
? { endpoint, method, headers }
: { endpoint, method, body, headers };
const request = new Request(requestAttrs);
return request.send();
}

View file

@ -41,4 +41,7 @@ export default {
STATUS_LIVE_QUERY: "/v1/fleet/status/live_query",
STATUS_RESULT_STORE: "/v1/fleet/status/result_store",
TEAMS: "/v1/fleet/teams",
TEAMS_MEMBERS: (teamId: number) => {
return `/v1/fleet/teams/${teamId}/users`;
},
};

View file

@ -1,11 +1,15 @@
import endpoints from "kolide/endpoints";
import { ITeam } from "interfaces/team";
import { INewMembersBody, IRemoveMembersBody, ITeam } from "interfaces/team";
import { ICreateTeamFormData } from "pages/admin/TeamManagementPage/components/CreateTeamModal/CreateTeamModal";
interface ITeamsResponse {
interface ILoadAllTeamsResponse {
teams: ITeam[];
}
interface ILoadTeamResponse {
team: ITeam;
}
export default (client: any) => {
return {
create: (formData: ICreateTeamFormData) => {
@ -21,8 +25,15 @@ export default (client: any) => {
const endpoint = `${client._endpoint(TEAMS)}/${teamId}`;
return client.authenticatedDelete(endpoint);
},
load: (teamId: number) => {
const { TEAMS } = endpoints;
const endpoint = client._endpoint(`${TEAMS}/${teamId}`);
loadAll: (page = 0, perPage = 100, globalFilter = "") => {
return client
.authenticatedGet(endpoint)
.then((response: ILoadTeamResponse) => response.team);
},
loadAll: ({ page = 0, perPage = 100, globalFilter = "" }) => {
const { TEAMS } = endpoints;
// TODO: add this query param logic to client class
@ -36,7 +47,7 @@ export default (client: any) => {
const teamsEndpoint = `${TEAMS}?${pagination}${searchQuery}`;
return client
.authenticatedGet(client._endpoint(teamsEndpoint))
.then((response: ITeamsResponse) => {
.then((response: ILoadAllTeamsResponse) => {
const { teams } = response;
return teams;
});
@ -49,5 +60,22 @@ export default (client: any) => {
.authenticatedPatch(updateTeamEndpoint, JSON.stringify(updateParams))
.then((response: ITeam) => response);
},
addMembers: (teamId: number, newMembers: INewMembersBody) => {
const { TEAMS_MEMBERS } = endpoints;
return client
.authenticatedPatch(
client._endpoint(TEAMS_MEMBERS(teamId)),
JSON.stringify(newMembers)
)
.then((response: ITeam) => response);
},
removeMembers: (teamId: number, removeMembers: IRemoveMembersBody) => {
const { TEAMS_MEMBERS } = endpoints;
return client.authenticatedDelete(
client._endpoint(TEAMS_MEMBERS(teamId)),
{},
JSON.stringify(removeMembers)
);
},
};
};

View file

@ -48,16 +48,13 @@ export default (client) => {
.then((response) => helpers.addGravatarUrlToResource(response.user));
},
// NOTE: this function signature is the same as entities/host#loadAll as this was quicker to just copy
// over. Ideally we'd want to remove the `selected` argument when we have more time, but for now
// is is left unused.
loadAll: (
loadAll: ({
page = 0,
perPage = 100,
selected = "",
globalFilter = "",
sortBy = []
) => {
sortBy = [],
teamId,
}) => {
const { USERS } = endpoints;
// TODO: add this query param logic to client class
@ -78,7 +75,12 @@ export default (client) => {
searchQuery = `&query=${globalFilter}`;
}
const userEndpoint = `${USERS}?${pagination}${searchQuery}${orderKeyParam}${orderDirection}`;
let teamQuery = "";
if (teamId !== undefined) {
teamQuery = `&team_id=${teamId}`;
}
const userEndpoint = `${USERS}?${pagination}${searchQuery}${orderKeyParam}${orderDirection}${teamQuery}`;
return client
.authenticatedGet(client._endpoint(userEndpoint))
.then((response) => {

View file

@ -65,7 +65,7 @@ describe("Kolide - API client (users)", () => {
const request = userMocks.loadAll.valid(bearerToken);
Kolide.setBearerToken(bearerToken);
return Kolide.users.loadAll().then(() => {
return Kolide.users.loadAll({}).then(() => {
expect(request.isDone()).toEqual(true);
});
});
@ -74,13 +74,12 @@ describe("Kolide - API client (users)", () => {
const request = userMocks.loadAll.validWithParams(bearerToken);
const page = 3;
const perPage = 100;
const selectedFilter = undefined;
const query = "testQuery";
const globalFilter = "testQuery";
const sortBy = [{ id: "name", desc: true }];
Kolide.setBearerToken(bearerToken);
return Kolide.users
.loadAll(page, perPage, selectedFilter, query, sortBy)
.loadAll({ page, perPage, globalFilter, sortBy })
.then(() => {
expect(request.isDone()).toEqual(true);
});

View file

@ -43,10 +43,10 @@ class Kolide extends Base {
this.teams = teamMethods(this);
}
authenticatedDelete(endpoint, overrideHeaders = {}) {
authenticatedDelete(endpoint, overrideHeaders = {}, body) {
const headers = this._authenticatedHeaders(overrideHeaders);
return Base._deleteRequest(endpoint, headers);
return Base._deleteRequest(endpoint, headers, body);
}
authenticatedGet(endpoint, overrideHeaders = {}) {

View file

@ -0,0 +1,13 @@
import React from "react";
const baseClass = "agent-options";
const AgentOptionsPage = (): JSX.Element => {
return (
<div className={baseClass}>
<h2>Agent Options Page</h2>
</div>
);
};
export default AgentOptionsPage;

View file

@ -0,0 +1 @@
export { default } from "./AgentOptionsPage";

View file

@ -0,0 +1,244 @@
import React, { useCallback, useEffect, useState } from "react";
// @ts-ignore
import memoize from "memoize-one";
import { IUser } from "interfaces/user";
import { INewMembersBody, ITeam } from "interfaces/team";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import { renderFlash } from "redux/nodes/notifications/actions";
// @ts-ignore
import userActions from "redux/nodes/entities/users/actions";
import teamActions from "redux/nodes/entities/teams/actions";
import TableContainer from "components/TableContainer";
import { useDispatch, useSelector } from "react-redux";
import EditUserModal from "../../../UserManagementPage/components/EditUserModal";
import { IFormData } from "../../../UserManagementPage/components/UserForm/UserForm";
import userManagementHelpers from "../../../UserManagementPage/helpers";
import AddMemberModal from "./components/AddMemberModal";
import EmptyMembers from "./components/EmptyMembers";
import RemoveMemberModal from "./components/RemoveMemberModal";
import {
generateTableHeaders,
generateDataSet,
} from "./MembersPageTableConfig";
const baseClass = "members";
interface IMembersPageProps {
params: {
team_id: string;
};
}
interface IRootState {
entities: {
users: {
loading: boolean;
data: { [id: number]: IUser };
};
teams: {
data: { [id: number]: ITeam };
};
};
}
const getTeams = (data: { [id: string]: ITeam }) => {
return Object.keys(data).map((teamId) => {
return data[teamId];
});
};
const memoizedGetTeams = memoize(getTeams);
const MembersPage = (props: IMembersPageProps): JSX.Element => {
const {
params: { team_id },
} = props;
const teamId = parseInt(team_id, 10);
const dispatch = useDispatch();
const loadingTableData = useSelector(
(state: IRootState) => state.entities.users.loading
);
const users = useSelector((state: IRootState) =>
generateDataSet(teamId, state.entities.users.data)
);
const team = useSelector((state: IRootState) => {
return state.entities.teams.data[teamId];
});
const teams = useSelector((state: IRootState) => {
return memoizedGetTeams(state.entities.teams.data);
});
const [showAddMemberModal, setShowAddMemberModal] = useState(false);
const [showRemoveMemberModal, setShowRemoveMemberModal] = useState(false);
const [showEditUserModal, setShowEditUserModal] = useState(false);
const [userEditing, setUserEditing] = useState<IUser>();
const toggleAddUserModal = useCallback(() => {
setShowAddMemberModal(!showAddMemberModal);
}, [showAddMemberModal, setShowAddMemberModal]);
const toggleRemoveMemberModal = useCallback(
(user?: IUser) => {
setShowRemoveMemberModal(!showRemoveMemberModal);
user ? setUserEditing(user) : setUserEditing(undefined);
},
[showRemoveMemberModal, setShowRemoveMemberModal, setUserEditing]
);
const toggleEditMemberModal = useCallback(
(user?: IUser) => {
setShowEditUserModal(!showEditUserModal);
user ? setUserEditing(user) : setUserEditing(undefined);
},
[showEditUserModal, setShowEditUserModal, setUserEditing]
);
const onRemoveMemberSubmit = useCallback(() => {
const removedUsers = { users: [{ id: userEditing?.id }] };
dispatch(teamActions.removeMembers(teamId, removedUsers))
.then(() => {
dispatch(
renderFlash("success", `Successfully removed ${userEditing?.name}`)
);
})
.catch(() => dispatch(renderFlash("error", "Remove failed")));
toggleRemoveMemberModal();
}, [
dispatch,
teamId,
userEditing?.id,
userEditing?.name,
toggleRemoveMemberModal,
]);
const onAddMemberSubmit = useCallback(
(newMembers: INewMembersBody) => {
dispatch(teamActions.addMembers(teamId, newMembers))
.then(() => {
dispatch(
renderFlash(
"success",
`${newMembers.users.length} members successfully added to ${team.name}.`
)
); // TODO: update team name
})
.catch(() => {
dispatch(
renderFlash("error", "Could not add members. Please try again.")
);
});
toggleAddUserModal();
},
[dispatch, teamId, toggleAddUserModal]
);
const onEditMemberSubmit = useCallback(
(formData: IFormData) => {
const updatedAttrs = userManagementHelpers.generateUpdateData(
userEditing as IUser,
formData
);
const userName = userEditing?.name;
dispatch(userActions.update(userEditing, updatedAttrs))
.then(() => {
dispatch(renderFlash("success", `Successfully edited ${userName}.`));
})
.catch(() => {
dispatch(
renderFlash(
"error",
`Could not edit ${userName}. Please try again.`
)
);
});
toggleEditMemberModal();
},
[dispatch, toggleEditMemberModal, userEditing]
);
// NOTE: this will fire on initial render, so we use this to get the list of
// users for this team, as well as use it as a handler when the table query
// changes.
const onQueryChange = useCallback(
(queryData) => {
const { pageIndex, pageSize, searchQuery } = queryData;
dispatch(
userActions.loadAll({
page: pageIndex,
perPage: pageSize,
globalFilter: searchQuery,
teamId,
})
);
},
[dispatch, teamId]
);
const onActionSelection = (action: string, user: IUser): void => {
switch (action) {
case "edit":
toggleEditMemberModal(user);
break;
case "remove":
toggleRemoveMemberModal(user);
break;
default:
}
};
const tableHeaders = generateTableHeaders(onActionSelection);
return (
<div className={baseClass}>
<p>Add, customize, and remove members from {team.name}.</p>
<h2>Members Page</h2>
<TableContainer
resultsTitle={"members"}
columns={tableHeaders}
data={users}
isLoading={loadingTableData}
defaultSortHeader={"name"}
defaultSortDirection={"asc"}
onActionButtonClick={toggleAddUserModal}
actionButtonText={"Add member"}
onQueryChange={onQueryChange}
inputPlaceHolder={"Search"}
emptyComponent={EmptyMembers}
/>
{showAddMemberModal ? (
<AddMemberModal
onCancel={toggleAddUserModal}
onSubmit={onAddMemberSubmit}
/>
) : null}
{showEditUserModal ? (
<EditUserModal
onCancel={toggleEditMemberModal}
onSubmit={onEditMemberSubmit}
defaultName={userEditing?.name}
defaultEmail={userEditing?.email}
defaultGlobalRole={userEditing?.global_role}
defaultTeams={userEditing?.teams}
defaultSSOEnabled={userEditing?.sso_enabled}
availableTeams={teams}
validationErrors={[]}
/>
) : null}
{showRemoveMemberModal ? (
<RemoveMemberModal
memberName={userEditing?.name || ""}
teamName={team.name}
onCancel={toggleRemoveMemberModal}
onSubmit={onRemoveMemberSubmit}
/>
) : null}
</div>
);
};
export default MembersPage;

View file

@ -0,0 +1,134 @@
import React from "react";
import TextCell from "components/TableContainer/DataTable/TextCell/TextCell";
import DropdownCell from "components/TableContainer/DataTable/DropdownCell";
import { IUser } from "interfaces/user";
import { ITeam } from "interfaces/team";
import { IDropdownOption } from "interfaces/dropdownOption";
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
}
interface ICellProps {
cell: {
value: any;
};
row: {
original: IUser;
};
}
interface IDataColumn {
title: string;
Header: ((props: IHeaderProps) => JSX.Element) | string;
accessor: string;
Cell: (props: ICellProps) => JSX.Element;
disableHidden?: boolean;
disableSortBy?: boolean;
}
interface IMembersTableData {
name: string;
email: string;
role: string;
teams: ITeam[];
actions: IDropdownOption[];
id: number;
}
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = (
actionSelectHandler: (value: string, user: IUser) => void
): IDataColumn[] => {
return [
{
title: "Name",
Header: "Name",
disableSortBy: true,
accessor: "name",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
{
title: "Email",
Header: "Email",
disableSortBy: true,
accessor: "email",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
{
title: "Role",
Header: "Role",
disableSortBy: true,
accessor: "role",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
{
title: "Actions",
Header: "Actions",
disableSortBy: true,
accessor: "actions",
Cell: (cellProps) => (
<DropdownCell
options={cellProps.cell.value}
onChange={(value: string) =>
actionSelectHandler(value, cellProps.row.original)
}
placeholder={"Actions"}
/>
),
},
];
};
const generateActionDropdownOptions = (): IDropdownOption[] => {
return [
{
label: "Edit",
disabled: false,
value: "edit",
},
{
label: "Remove",
disabled: false,
value: "remove",
},
];
};
const generateRole = (teamId: number, teams: ITeam[]): string => {
return teams.find((team) => teamId === team.id)?.role ?? "";
};
const enhanceMembersData = (
teamId: number,
users: {
[id: number]: IUser;
}
): IMembersTableData[] => {
return Object.values(users).map((user) => {
return {
name: user.name,
email: user.email,
role: generateRole(teamId, user.teams),
teams: user.teams,
sso_enabled: user.sso_enabled,
global_role: user.global_role,
actions: generateActionDropdownOptions(),
id: user.id,
};
});
};
const generateDataSet = (
teamId: number,
users: {
[id: number]: IUser;
}
): IMembersTableData[] => {
return [...enhanceMembersData(teamId, users)];
};
export { generateTableHeaders, generateDataSet };

View file

@ -0,0 +1,71 @@
import React, { useCallback, useState } from "react";
import { IUser } from "interfaces/user";
import { INewMembersBody } from "interfaces/team";
import endpoints from "kolide/endpoints";
import Modal from "components/modals/Modal";
import Button from "components/buttons/Button";
import AutocompleteDropdown from "components/forms/fields/AutocompleteDropdown";
const baseClass = "add-member-modal";
interface IAddMemberModal {
onCancel: () => void;
onSubmit: (userIds: INewMembersBody) => void;
}
const AddMemberModal = (props: IAddMemberModal): JSX.Element => {
const { onCancel, onSubmit } = props;
const [selectedMembers, setSelectedMembers] = useState([]);
const onChangeDropdown = useCallback(
(values) => {
setSelectedMembers(values);
},
[setSelectedMembers]
);
const onFormSubmit = useCallback(() => {
const userIds = selectedMembers.map((member: IUser) => {
return { id: member.id, role: "observer" };
});
onSubmit({ users: userIds });
}, [selectedMembers, onSubmit]);
return (
<Modal onExit={onCancel} title={"Add Members"} className={baseClass}>
<form className={`${baseClass}__form`}>
<AutocompleteDropdown
id={"member-autocomplete"}
resourceUrl={endpoints.USERS}
onChange={onChangeDropdown}
placeholder={"Search users by name"}
value={selectedMembers}
valueKey={"id"}
labelKey={"name"}
/>
<div className={`${baseClass}__btn-wrap`}>
<Button
disabled={selectedMembers.length === 0}
className={`${baseClass}__btn`}
type="button"
variant="brand"
onClick={onFormSubmit}
>
Add Member
</Button>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
variant="inverse"
>
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default AddMemberModal;

View file

@ -0,0 +1,11 @@
.add-member-modal {
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
margin-top: $pad-xxlarge;
}
&__btn {
margin-left: 12px;
}
}

View file

@ -0,0 +1 @@
export { default } from "./AddMemberModal";

View file

@ -0,0 +1,21 @@
import React from "react";
const baseClass = "empty-members";
const EmptyMembers = (): JSX.Element => {
return (
<div className={baseClass}>
<div className={`${baseClass}__inner`}>
<div className={`${baseClass}__empty-filter-results`}>
<h1>We couldn&apos;t find any members.</h1>
<p>
Expecting to see new members? Try again in a few seconds as the
system catches up.
</p>
</div>
</div>
</div>
);
};
export default EmptyMembers;

View file

@ -0,0 +1,35 @@
.empty-members {
display: flex;
flex-direction: column;
align-items: center;
margin-top: $pad-xxxlarge;
&__inner {
display: flex;
flex-direction: row;
h1 {
font-size: $small;
font-weight: $bold;
margin-bottom: $pad-medium;
}
img {
width: 176px;
margin-right: $pad-xlarge;
}
p {
color: $core-fleet-black;
font-weight: $regular;
font-size: $x-small;
margin: 0;
}
}
&__empty-filter-results {
display: flex;
flex-direction: column;
width: 350px;
}
}

View file

@ -0,0 +1 @@
export { default } from "./EmptyMembers";

View file

@ -0,0 +1,14 @@
import React from "react";
const baseClass = "no-members";
const NoMembers = (): JSX.Element => {
return (
<div className={baseClass}>
<h2>Rally the fleet</h2>
<p>Add your first team members and start organizing their permissions.</p>
</div>
);
};
export default NoMembers;

View file

@ -0,0 +1 @@
export { default } from "./NoMembers";

View file

@ -0,0 +1,52 @@
import React from "react";
import Modal from "components/modals/Modal";
import Button from "components/buttons/Button";
const baseClass = "remove-member-modal";
interface IDeleteTeamModalProps {
memberName: string;
teamName: string;
onSubmit: () => void;
onCancel: () => void;
}
const RemoveMemberModal = (props: IDeleteTeamModalProps): JSX.Element => {
const { memberName, teamName, onSubmit, onCancel } = props;
return (
<Modal title={"Delete team"} onExit={onCancel} className={baseClass}>
<form className={`${baseClass}__form`}>
<p>
You are about to remove{" "}
<span className={`${baseClass}__name`}>{memberName}</span> from{" "}
<span className={`${baseClass}__team-name`}>{teamName}</span>.
</p>
<p>
If {memberName} is not a member of any other team, they will lose
access to Fleet.
</p>
<div className={`${baseClass}__btn-wrap`}>
<Button
className={`${baseClass}__btn`}
type="button"
variant="alert"
onClick={onSubmit}
>
Remove
</Button>
<Button
className={`${baseClass}__btn`}
onClick={onCancel}
variant="inverse"
>
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default RemoveMemberModal;

View file

@ -0,0 +1,20 @@
.remove-member-modal {
&__name,
&__team-name {
font-weight: $bold;
}
&__warning {
color: $ui-error;
}
&__btn-wrap {
display: flex;
flex-direction: row-reverse;
margin-top: $pad-xxlarge;
}
&__btn {
margin-left: 12px;
}
}

View file

@ -0,0 +1 @@
export { default } from "./RemoveMemberModal";

View file

@ -0,0 +1 @@
export { default } from "./MembersPage";

View file

@ -0,0 +1,194 @@
import React, { useState, useEffect, useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router";
import { push } from "react-router-redux";
import { Tab, TabList, Tabs } from "react-tabs";
import PATHS from "router/paths";
import { ITeam } from "interfaces/team";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import { renderFlash } from "redux/nodes/notifications/actions";
import teamActions from "redux/nodes/entities/teams/actions";
import Button from "components/buttons/Button";
import DeleteTeamModal from "../components/DeleteTeamModal";
import EditTeamModal from "../components/EditTeamModal";
import { IEditTeamFormData } from "../components/EditTeamModal/EditTeamModal";
const baseClass = "team-details";
interface ITeamDetailsSubNavItem {
name: string;
getPathname: (id: number) => string;
}
const teamDetailsSubNav: ITeamDetailsSubNavItem[] = [
{
name: "Member",
getPathname: PATHS.TEAM_DETAILS_MEMBERS,
},
{
name: "Agent options",
getPathname: PATHS.TEAM_DETAILS_OPTIONS,
},
];
interface IRootState {
entities: {
teams: {
loading: boolean;
data: { [id: number]: ITeam };
};
};
}
interface ITeamDetailsPageProps {
children: JSX.Element;
params: {
team_id: number;
};
location: {
pathname: string;
};
}
const generateUpdateData = (
currentTeamData: ITeam,
formData: IEditTeamFormData
): IEditTeamFormData | null => {
if (currentTeamData.name !== formData.name) {
return {
name: formData.name,
};
}
return null;
};
const getTabIndex = (path: string, teamId: number): number => {
return teamDetailsSubNav.findIndex((navItem) => {
return navItem.getPathname(teamId).includes(path);
});
};
const TeamDetailsWrapper = (props: ITeamDetailsPageProps): JSX.Element => {
const {
children,
location: { pathname },
params: { team_id },
} = props;
const isLoadingTeams = useSelector(
(state: IRootState) => state.entities.teams.loading
);
const team = useSelector((state: IRootState) => {
return state.entities.teams.data[team_id];
});
const [showDeleteTeamModal, setShowDeleteTeamModal] = useState(false);
const [showEditTeamModal, setShowEditTeamModal] = useState(false);
const dispatch = useDispatch();
const navigateToNav = (i: number): void => {
const navPath = teamDetailsSubNav[i].getPathname(team_id);
dispatch(push(navPath));
};
useEffect(() => {
dispatch(teamActions.loadAll({ perPage: 500 }));
}, []);
const toggleDeleteTeamModal = useCallback(() => {
setShowDeleteTeamModal(!showDeleteTeamModal);
}, [showDeleteTeamModal, setShowDeleteTeamModal]);
const toggleEditTeamModal = useCallback(() => {
setShowEditTeamModal(!showEditTeamModal);
}, [showEditTeamModal, setShowEditTeamModal]);
const onDeleteSubmit = useCallback(() => {
dispatch(teamActions.destroy(team?.id))
.then(() => {
dispatch(renderFlash("success", "Team removed"));
dispatch(push(PATHS.ADMIN_TEAMS));
// TODO: error handling
})
.catch(() => null);
toggleDeleteTeamModal();
}, [dispatch, toggleDeleteTeamModal, team?.id]);
const onEditSubmit = useCallback(
(formData: IEditTeamFormData) => {
const updatedAttrs = generateUpdateData(team, formData);
// no updates, so no need for a request.
if (updatedAttrs === null) {
toggleEditTeamModal();
return;
}
dispatch(teamActions.update(team?.id, updatedAttrs))
.then(() => {
dispatch(renderFlash("success", "Team updated"));
// TODO: error handling
})
.catch(() => null);
toggleEditTeamModal();
},
[dispatch, toggleEditTeamModal, team]
);
if (isLoadingTeams || team === undefined) return <p>loading...</p>;
return (
<div className={baseClass}>
<div className={`${baseClass}__nav-header`}>
<Link className={`${baseClass}__back-link`} to={PATHS.ADMIN_TEAMS}>
Back to teams
</Link>
<div className={`${baseClass}__team-header`}>
<div className={`${baseClass}__team-details`}>
<h1>{team.name}</h1>
<span className={`${baseClass}__host-count`}>0 hosts</span>
</div>
<div className={`${baseClass}__team-actions`}>
<Button onClick={() => console.log("click")}>Add hosts</Button>
<Button onClick={toggleEditTeamModal}>Edit team</Button>
<Button onClick={toggleDeleteTeamModal}>Delete team</Button>
</div>
</div>
<Tabs
selectedIndex={getTabIndex(pathname, team_id)}
onSelect={(i) => navigateToNav(i)}
>
<TabList>
{teamDetailsSubNav.map((navItem) => {
// Bolding text when the tab is active causes a layout shift
// so we add a hidden pseudo element with the same text string
return (
<Tab key={navItem.name} data-text={navItem.name}>
{navItem.name}
</Tab>
);
})}
</TabList>
</Tabs>
{showDeleteTeamModal ? (
<DeleteTeamModal
onCancel={toggleDeleteTeamModal}
onSubmit={onDeleteSubmit}
name={team.name}
/>
) : null}
{showEditTeamModal ? (
<EditTeamModal
onCancel={toggleEditTeamModal}
onSubmit={onEditSubmit}
defaultName={team.name}
/>
) : null}
</div>
{children}
</div>
);
};
export default TeamDetailsWrapper;

View file

@ -0,0 +1,92 @@
.team-details {
padding: 25px $pad-xlarge 50px; // different to pad sticky subnav properly
&__back-link {
color: $core-vibrant-blue;
font-size: $x-small;
font-weight: $bold;
text-decoration: none;
}
&__nav-header {
padding-top: 15px;
position: sticky;
top: 0;
background-color: $core-white;
z-index: 2;
}
&__team-header {
margin-top: $pad-large;
display: flex;
justify-content: space-between;
margin-bottom: $pad-medium;
}
&__team-details {
display: flex;
align-items: center;
}
&__host-count {
background-color: $ui-vibrant-blue-25;
font-size: $xx-small;
font-weight: $bold;
border-radius: 30px;
margin-left: $pad-medium;
padding: 4px 12px;
}
&__team-actions {
button {
margin-left: $pad-medium;
}
}
.react-tabs {
.react-tabs__tab-list {
border-bottom: 1px solid $ui-gray;
}
&__tab {
font-size: $x-small;
border: none;
padding: 0 0 $pad-medium 0;
margin-left: $pad-xxlarge;
display: inline-flex;
flex-direction: column;
align-items: center;
&:focus {
box-shadow: none;
outline: auto;
}
// Bolding text when the tab is active causes a layout shift
// so we add a hidden pseudo element with the same text string
&:before {
content: attr(data-text);
height: 0;
visibility: hidden;
overflow: hidden;
user-select: none;
pointer-events: none;
font-weight: $bold;
}
&:after {
background-color: transparent;
}
&:first-child {
margin-left: 0;
}
}
&__tab--selected,
&__tab--selected:focus {
font-weight: $bold;
border-bottom: 1px solid $core-vibrant-blue;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./TeamDetailsWrapper";

View file

@ -1,10 +1,11 @@
import React from "react";
import StatusCell from "components/TableContainer/DataTable/StatusCell/StatusCell";
import TextCell from "components/TableContainer/DataTable/TextCell/TextCell";
import LinkCell from "components/TableContainer/DataTable/LinkCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import DropdownCell from "components/TableContainer/DataTable/DropdownCell";
import { ITeam } from "interfaces/team";
import { IDropdownOption } from "interfaces/dropdownOption";
import PATHS from "router/paths";
interface IHeaderProps {
column: {
@ -31,12 +32,8 @@ interface IDataColumn {
disableSortBy?: boolean;
}
interface ITeamTableData {
name: string;
hosts: number;
members: number;
interface ITeamTableData extends ITeam {
actions: IDropdownOption[];
id: number;
}
// NOTE: cellProps come from react-table
@ -50,21 +47,26 @@ const generateTableHeaders = (
Header: "Name",
disableSortBy: true,
accessor: "name",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
Cell: (cellProps) => (
<LinkCell
value={cellProps.cell.value}
path={PATHS.TEAM_DETAILS_MEMBERS(cellProps.row.original.id)}
/>
),
},
// TODO: need to add this info to API
{
title: "Hosts",
Header: "Hosts",
disableSortBy: true,
accessor: "hosts",
accessor: "host_count",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
{
title: "Members",
Header: "Members",
disableSortBy: true,
accessor: "members",
accessor: "user_count",
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
{
@ -104,9 +106,10 @@ const generateActionDropdownOptions = (): IDropdownOption[] => {
const enhanceTeamData = (teams: { [id: number]: ITeam }): ITeamTableData[] => {
return Object.values(teams).map((team) => {
return {
description: team.description,
name: team.name,
hosts: team.hosts,
members: team.members,
host_count: team.host_count,
user_count: team.user_count,
actions: generateActionDropdownOptions(),
id: team.id,
};

View file

@ -1,12 +1,10 @@
import React, { useState, useCallback } from "react";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import Modal from "components/modals/Modal";
// @ts-ignore
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
import Button from "components/buttons/Button";
import InfoBanner from "components/InfoBanner/InfoBanner";
// @ts-ignore
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
const baseClass = "create-team-modal";

View file

@ -1,7 +1,5 @@
import React from "react";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import Modal from "components/modals/Modal";
import Button from "components/buttons/Button";

View file

@ -1,12 +1,9 @@
import React, { useState, useCallback } from "react";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import Modal from "components/modals/Modal";
// @ts-ignore
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon";
import Button from "components/buttons/Button";
import InfoBanner from "components/InfoBanner/InfoBanner";
const baseClass = "edit-team-modal";

View file

@ -87,11 +87,11 @@ export class UserManagementPage extends Component {
componentDidMount() {
const { dispatch } = this.props;
dispatch(teamActions.loadAll());
dispatch(teamActions.loadAll({}));
}
onEditUser = (formData) => {
const { currentUser, users, invites, dispatch } = this.props;
const { currentUser, dispatch } = this.props;
const { userEditing } = this.state;
const { toggleEditUserModal, getUser } = this;
@ -215,9 +215,14 @@ export class UserManagementPage extends Component {
sortBy = [{ id: sortHeader, direction: sortDirection }];
}
dispatch(
userActions.loadAll(pageIndex, pageSize, undefined, searchQuery, sortBy)
userActions.loadAll({
page: pageIndex,
perPage: pageSize,
globalFilter: searchQuery,
sortBy,
})
);
dispatch(inviteActions.loadAll(pageIndex, pageSize, searchQuery, sortBy)); // TODO: add search params when API supports it
dispatch(inviteActions.loadAll(pageIndex, pageSize, searchQuery, sortBy));
};
onActionSelect = (action, user) => {

View file

@ -0,0 +1,57 @@
import React from "react";
import { ITeam } from "interfaces/team";
import Modal from "components/modals/Modal";
import UserForm from "../UserForm";
import { IFormData } from "../UserForm/UserForm";
interface IEditUserModalProps {
onCancel: () => void;
onSubmit: (formData: IFormData) => void;
defaultName?: string;
defaultEmail?: string;
defaultGlobalRole?: string | null;
defaultTeams?: ITeam[];
defaultSSOEnabled?: boolean;
availableTeams: ITeam[];
validationErrors: any[];
}
const baseClass = "edit-user-modal";
const EditUserModal = (props: IEditUserModalProps) => {
const {
onCancel,
onSubmit,
defaultName,
defaultEmail,
defaultGlobalRole,
defaultTeams,
defaultSSOEnabled,
availableTeams,
validationErrors,
} = props;
return (
<Modal
title="Edit user"
onExit={onCancel}
className={`${baseClass}__edit-user-modal`}
>
<UserForm
validationErrors={validationErrors}
defaultName={defaultName}
defaultEmail={defaultEmail}
defaultGlobalRole={defaultGlobalRole}
defaultTeams={defaultTeams}
onCancel={onCancel}
onSubmit={onSubmit}
canUseSSO={defaultSSOEnabled}
availableTeams={availableTeams}
submitText={"Save"}
/>
</Modal>
);
};
export default EditUserModal;

View file

@ -0,0 +1 @@
export { default } from "./EditUserModal";

View file

@ -57,9 +57,10 @@ const generateSelectedTeamData = (
return teamsFormList.reduce((selectedTeams: ITeam[], teamItem) => {
if (teamItem.isChecked) {
selectedTeams.push({
description: teamItem.description,
id: teamItem.id,
hosts: teamItem.hosts,
members: teamItem.members,
host_count: teamItem.host_count,
user_count: teamItem.user_count,
name: teamItem.name,
role: teamItem.role,
});

View file

@ -42,27 +42,28 @@ const globalUserRoles = [
},
];
interface IFormData {
export interface IFormData {
email: string;
name: string;
sso_enabled: boolean;
currentUserId: number;
global_role: string | null;
teams: ITeam[];
currentUserId?: number;
invited_by?: number;
}
interface ICreateUserFormProps {
availableTeams: ITeam[];
currentUserId: number;
onCancel: () => void;
onSubmit: (formData: IFormData) => void;
canUseSSO: boolean;
submitText: string;
canUseSSO?: boolean;
defaultName?: string;
defaultEmail?: string;
currentUserId?: number;
defaultGlobalRole?: string | null;
defaultTeams: ITeam[];
defaultTeams?: ITeam[];
validationErrors: any[]; // TODO: proper interface for validationErrors
}
interface ICreateUserFormState {
@ -88,9 +89,9 @@ class UserForm extends Component<ICreateUserFormProps, ICreateUserFormState> {
formData: {
email: props.defaultEmail || "",
name: props.defaultName || "",
sso_enabled: false,
sso_enabled: props.canUseSSO || false,
global_role: props.defaultGlobalRole || null,
teams: props.defaultTeams,
teams: props.defaultTeams || [],
currentUserId: props.currentUserId,
},
isGlobalUser: props.defaultGlobalRole !== null,

View file

@ -0,0 +1 @@
export { default } from "./userManagementHelpers";

View file

@ -0,0 +1,36 @@
import { userStub, userTeamStub } from "test/stubs";
import { IFormData } from "../components/UserForm/UserForm";
import userManagementHelpers from "./userManagementHelpers";
describe("userManagementHelpers module", () => {
describe("generateUpdatedData function", () => {
it("returns an object with only the difference between the two", () => {
const updatedTeam = {
...userTeamStub,
role: "maintainer",
};
const newTeam = {
...userTeamStub,
id: 2,
role: "observer",
};
const formData: IFormData = {
email: "newemail@test.com",
sso_enabled: false,
name: "Gnar Mike",
global_role: "admin",
teams: [updatedTeam, newTeam],
};
const updatedData = userManagementHelpers.generateUpdateData(
userStub,
formData
);
expect(updatedData).toEqual({
email: "newemail@test.com",
teams: [updatedTeam, newTeam],
});
});
});
});

View file

@ -0,0 +1,54 @@
import { isEqual } from "lodash";
import { IInvite } from "interfaces/invite";
import { IUser, IUserUpdateBody } from "interfaces/user";
import { IFormData } from "../components/UserForm/UserForm";
type ICurrentUserData = Pick<
IUser,
"global_role" | "teams" | "name" | "email" | "sso_enabled"
>;
/**
* Helper function that will compare the current user with data from the editing
* form and return an object with the difference between the two. This can be
* be used for PATCH updates when updating a user.
* @param currentUserData
* @param formData
*/
const generateUpdateData = (
currentUserData: IUser | IInvite,
formData: IFormData
): IUserUpdateBody => {
const updatableFields = [
"global_role",
"teams",
"name",
"email",
"sso_enabled",
];
return Object.keys(formData).reduce<IUserUpdateBody>(
(updatedAttributes, attr) => {
// attribute can be updated and is different from the current value.
if (
updatableFields.includes(attr) &&
!isEqual(
formData[attr as keyof ICurrentUserData],
currentUserData[attr as keyof ICurrentUserData]
)
) {
// Note: ignore TS error as we will never have undefined set to an
// updatedAttributes value if we get to this code.
// @ts-ignore
updatedAttributes[attr as keyof ICurrentUserData] =
formData[attr as keyof ICurrentUserData];
}
return updatedAttributes;
},
{}
);
};
export default {
generateUpdateData,
};

View file

@ -11,6 +11,7 @@ import {
humanHostLastSeen,
humanHostDetailUpdated,
} from "kolide/helpers";
import PATHS from "../../../router/paths";
interface IHeaderProps {
column: {
@ -37,6 +38,13 @@ interface IHostDataColumn {
disableSortBy?: boolean;
}
const lastSeenTime = (status: string, seenTime: string): string => {
if (status !== "online") {
return `Last Seen: ${humanHostLastSeen(seenTime)} UTC`;
}
return "Online";
};
const hostTableHeaders: IHostDataColumn[] = [
{
title: "Hostname",
@ -48,7 +56,14 @@ const hostTableHeaders: IHostDataColumn[] = [
),
accessor: "hostname",
Cell: (cellProps) => (
<LinkCell value={cellProps.cell.value} host={cellProps.row.original} />
<LinkCell
value={cellProps.cell.value}
path={PATHS.HOST_DETAILS(cellProps.row.original)}
title={lastSeenTime(
cellProps.row.original.status,
cellProps.row.original.seen_time
)}
/>
),
disableHidden: true,
},

View file

@ -1,20 +1,7 @@
import Kolide from "kolide";
import config from "./config";
const { actions } = config;
export const LOAD_PAGINATED = "LOAD_PAGINATED";
export const loadPaginated = () => {
const { loadRequest } = actions;
return (dispatch) => {
dispatch(loadRequest());
return Kolide.hosts;
};
};
export default {
...actions,
};

View file

@ -1,3 +1,71 @@
import { INewMembersBody, IRemoveMembersBody, ITeam } from "interfaces/team";
// ignore TS error for now until these are rewritten in ts.
// @ts-ignore
import Kolide from "kolide";
// @ts-ignore
import { formatErrorResponse } from "redux/nodes/entities/base/helpers";
import config from "./config";
export default config.actions;
const { actions } = config;
const { loadRequest, successAction, updateSuccess } = actions;
export const ADD_MEMBERS_FAILURE = "ADD_MEMBERS_FAILURE";
export const addMembersFailure = (errors: any) => {
return {
type: ADD_MEMBERS_FAILURE,
payload: { errors },
};
};
export const REMOVE_MEMBERS_FAILURE = "REMOVE_MEMBERS_FAILURE";
export const removeMembersFailure = (errors: any) => {
return {
type: REMOVE_MEMBERS_FAILURE,
payload: { errors },
};
};
export const addMembers = (
teamId: number,
newMembers: INewMembersBody
): any => {
return (dispatch: any) => {
dispatch(loadRequest()); // TODO: figure out better way to do this. This causes page flash
return Kolide.teams
.addMembers(teamId, newMembers)
.then((res: { team: ITeam }) => {
return dispatch(successAction(res.team, updateSuccess)); // TODO: come back and figure out updating team entity.
})
.catch((res: any) => {
const errorsObject = formatErrorResponse(res);
dispatch(addMembersFailure(errorsObject));
throw errorsObject;
});
};
};
export const removeMembers = (
teamId: number,
removedMembers: IRemoveMembersBody
) => {
return (dispatch: any) => {
dispatch(loadRequest()); // TODO: figure out better way to do this. This causes page flash
return Kolide.teams
.removeMembers(teamId, removedMembers)
.then((res: { team: ITeam }) => {
return dispatch(successAction(res.team, updateSuccess)); // TODO: come back and figure out updating team entity.
})
.catch((res: any) => {
const errorsObject = formatErrorResponse(res);
dispatch(removeMembersFailure(errorsObject));
throw errorsObject;
});
};
};
export default {
...actions,
addMembers,
removeMembers,
};

View file

@ -12,6 +12,7 @@ export default new Config({
createFunc: Kolide.teams.create,
destroyFunc: Kolide.teams.destroy,
entityName: "teams",
loadFunc: Kolide.teams.load,
loadAllFunc: Kolide.teams.loadAll,
schema: TEAMS,
updateFunc: Kolide.teams.update,

View file

@ -12,6 +12,7 @@ import { syncHistoryWithStore } from "react-router-redux";
import AdminAppSettingsPage from "pages/admin/AppSettingsPage";
import AdminUserManagementPage from "pages/admin/UserManagementPage";
import AdminTeamManagementPage from "pages/admin/TeamManagementPage";
import TeamDetailsWrapper from "pages/admin/TeamManagementPage/TeamDetailsWrapper";
import AllPacksPage from "pages/packs/AllPacksPage";
import App from "components/App";
import AuthenticatedAdminRoutes from "components/AuthenticatedAdminRoutes";
@ -35,6 +36,8 @@ import Fleet404 from "pages/Fleet404";
import Fleet500 from "pages/Fleet500";
import UserSettingsPage from "pages/UserSettingsPage";
import SettingsWrapper from "pages/admin/SettingsWrapper/SettingsWrapper";
import MembersPage from "pages/admin/TeamManagementPage/TeamDetailsWrapper/MembersPagePage";
import AgentOptionsPage from "pages/admin/TeamManagementPage/TeamDetailsWrapper/AgentOptionsPage";
import PATHS from "router/paths";
import store from "redux/store";
@ -65,6 +68,10 @@ const routes = (
<Route path="users" component={AdminUserManagementPage} />
<Route path="teams" component={AdminTeamManagementPage} />
</Route>
<Route path="teams/:team_id" component={TeamDetailsWrapper}>
<Route path="members" component={MembersPage} />
<Route path="options" component={AgentOptionsPage} />
</Route>
</Route>
<Route path="hosts">
<Route path="manage" component={ManageHostsPage} />

View file

@ -26,6 +26,12 @@ export default {
HOST_DETAILS: (host: IHost): string => {
return `${URL_PREFIX}/hosts/${host.id}`;
},
TEAM_DETAILS_MEMBERS: (teamId: number): string => {
return `${URL_PREFIX}/settings/teams/${teamId}/members`;
},
TEAM_DETAILS_OPTIONS: (teamId: number): string => {
return `${URL_PREFIX}/settings/teams/${teamId}/options`;
},
MANAGE_PACKS: `${URL_PREFIX}/packs/manage`,
NEW_PACK: `${URL_PREFIX}/packs/new`,
MANAGE_QUERIES: `${URL_PREFIX}/queries/manage`,

View file

@ -1,3 +1,6 @@
import { IUser } from "interfaces/user";
import { ITeam } from "interfaces/team";
export const adminUserStub = {
id: 1,
admin: true,
@ -150,13 +153,29 @@ export const scheduledQueryStub = {
snapshot: true,
};
export const userStub = {
export const teamStub: ITeam = {
description: "This is the test team",
host_count: 10,
id: 1,
name: "Test Team",
user_count: 5,
};
export const userTeamStub: ITeam = {
...teamStub,
role: "observer",
};
export const userStub: IUser = {
id: 1,
admin: false,
email: "hi@gnar.dog",
enabled: true,
force_password_reset: false,
global_role: "admin",
gravatar_url: "https://image.com",
name: "Gnar Mike",
sso_enabled: false,
username: "gnardog",
teams: [{ ...userTeamStub }],
};
const queryResultStub = {

View file

@ -10,7 +10,6 @@
"cypress:open": "cypress open"
},
"dependencies": {
"@types/react-tooltip": "^4.2.4",
"ace-builds": "1.3.1",
"autoprefixer": "^9.4.3",
"bourbon": "^5.0.0",
@ -144,14 +143,18 @@
"@types/expect": "1.20.3",
"@types/js-md5": "^0.4.2",
"@types/lodash": "^4.14.168",
"@types/memoize-one": "^5.1.2",
"@types/mocha": "2.2.48",
"@types/node": "^14.14.37",
"@types/prop-types": "^15.7.3",
"@types/react": "^17.0.2",
"@types/react-redux": "^7.1.16",
"@types/react-router": "^3.0.0",
"@types/react-router-redux": "^5.0.18",
"@types/react-select": "1.3.0",
"@types/react-table": "^7.0.28",
"@types/react-tabs": "^2.3.2",
"@types/react-tooltip": "^4.2.4",
"@typescript-eslint/eslint-plugin": "^4.15.2",
"@typescript-eslint/parser": "^4.15.2",
"babel-core": "^7.0.0-bridge.0",

View file

@ -1556,6 +1556,11 @@
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934"
integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==
"@types/history@^3":
version "3.2.4"
resolved "https://registry.yarnpkg.com/@types/history/-/history-3.2.4.tgz#0b6c62240d1fac020853aa5608758991d9f6ef3d"
integrity sha512-q7x8QeCRk2T6DR2UznwYW//mpN5uNlyajkewH2xd1s1ozCS4oHFRg2WMusxwLFlE57EkUYsd/gCapLBYzV3ffg==
"@types/hoist-non-react-statics@^3.3.0":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
@ -1624,6 +1629,13 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008"
integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==
"@types/memoize-one@^5.1.2":
version "5.1.2"
resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-5.1.2.tgz#9bed4199822f6ae759b2a0de855f3254104709b5"
integrity sha512-9lItlM8Bf1DPvm8p4zE0vUn7YoTktEYgLd6Eva/PT0its200xmhaYrCMG/Y8615/f62SXOrDWMIEPk/xV+DQpw==
dependencies:
memoize-one "*"
"@types/mocha@2.2.48":
version "2.2.48"
resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-2.2.48.tgz#3523b126a0b049482e1c3c11877460f76622ffab"
@ -1687,6 +1699,21 @@
"@types/history" "*"
"@types/react" "*"
"@types/react-router@^3.0.0":
version "3.0.24"
resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-3.0.24.tgz#f924569538ea78a0b0d70892900a0d99ed6d7354"
integrity sha512-cSpMXzI0WocB5/UmySAtGlvG5w3m2mNvU6FgYFFWGqt6KywI7Ez+4Z9mEkglcAAGaP+voZjVg+BJP86bkVrSxQ==
dependencies:
"@types/history" "^3"
"@types/react" "*"
"@types/react-select@1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-1.3.0.tgz#f6a7a2df9d41109d6cfa8670d08a087f2bc6a346"
integrity sha512-a9XMXDxYTCTFAHGdQ86hWlhdFWtcXCRGN3iXONB7w1vNcO9+J4CvAUsYgWNekIcMMNvpKK/dIrsVaTLSERnwig==
dependencies:
"@types/react" "*"
"@types/react-table@^7.0.28":
version "7.0.28"
resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-7.0.28.tgz#763383c3e7a285892ee64f311ee97a9c254b2bb0"
@ -8677,6 +8704,11 @@ mem@^4.0.0:
mimic-fn "^2.0.0"
p-is-promise "^2.0.0"
memoize-one@*:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
memoize-one@^5:
version "5.0.5"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.5.tgz#8cd3809555723a07684afafcd6f756072ac75d7e"