mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
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:
parent
7490878029
commit
a85476c23b
65 changed files with 1474 additions and 118 deletions
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./HeaderCell";
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./LinkCell";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./StatusCell";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./TextCell";
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AutocompleteDropdown";
|
||||
|
|
@ -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(
|
||||
|
|
@ -19,5 +19,6 @@ export interface IInvite {
|
|||
invited_by: number;
|
||||
name: string;
|
||||
teams: ITeam[];
|
||||
sso_enabled: boolean;
|
||||
global_role: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
.agent-options {}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AgentOptionsPage";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.members {
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.add-member-modal {
|
||||
&__btn-wrap {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: $pad-xxlarge;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./AddMemberModal";
|
||||
|
|
@ -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'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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./EmptyMembers";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./NoMembers";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./RemoveMemberModal";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./MembersPage";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./TeamDetailsWrapper";
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./EditUserModal";
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1
frontend/pages/admin/UserManagementPage/helpers/index.ts
Normal file
1
frontend/pages/admin/UserManagementPage/helpers/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./userManagementHelpers";
|
||||
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
32
yarn.lock
32
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue