[Frontend] Create API-only users that only have access to customer-defined Fleet API endpoints (#43281)

**Related issue:** Resolves #42879

* Full UI for API-only user management: create/edit flows, fleet/role
assignment, selectable API endpoint permissions, and one-time API key
display.
* New reusable components: API user form, endpoint selector, API access
section, and API key presentation.
* Admin workflow switched from in-page modals to dedicated pages and
streamlined action dropdown navigation.
* Layout and styling refinements for user management, team lists, and
dropdown behaviors.

---------

Co-authored-by: Juan Fernandez <juan@fleetdm.com>
This commit is contained in:
Nico 2026-04-20 08:18:02 -05:00 committed by GitHub
parent 2a8803884b
commit 578f35292c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1613 additions and 328 deletions

View file

@ -0,0 +1 @@
* Added UI pages for creating and editing API-only users with support for fleet assignment, role selection, and API endpoint access control.

View file

@ -29,6 +29,7 @@ interface IActionsDropdownProps {
menuAlign?: "right" | "left" | "default";
menuPlacement?: "top" | "bottom" | "auto";
variant?: "button" | "brand-button" | "small-button";
buttonLabel?: string;
}
const getOptionBackgroundColor = (state: { isFocused: boolean }) => {
@ -122,6 +123,7 @@ const ActionsDropdown = ({
menuAlign = "default",
menuPlacement = "bottom",
variant,
buttonLabel,
}: IActionsDropdownProps): JSX.Element => {
const dropdownClassnames = classnames(baseClass, className);
@ -161,7 +163,7 @@ const ActionsDropdown = ({
aria-haspopup="listbox"
aria-expanded={menuIsOpen}
>
<span>Actions</span>
<span>{buttonLabel || "Actions"}</span>
<Icon
name="chevron-down"
color="core-fleet-white"

View file

@ -0,0 +1,12 @@
export interface IApiEndpointRef {
method: string;
path: string;
}
export interface IApiEndpoint extends IApiEndpointRef {
display_name: string;
deprecated: boolean;
}
/** Unique key for an endpoint since there's no `id` field */
export const endpointKey = (ep: IApiEndpointRef) => `${ep.method} ${ep.path}`;

View file

@ -1,6 +1,7 @@
import PropTypes from "prop-types";
import teamInterface, { ITeam } from "./team";
import { IUserSettings } from "./config";
import { IApiEndpointRef } from "./api_endpoint";
export default PropTypes.shape({
created_at: PropTypes.string,
@ -59,6 +60,7 @@ export interface IUser {
api_only: boolean;
teams: ITeam[];
fleets: ITeam[]; // This will eventually replace `teams`, but for now we need both to avoid breaking changes.
api_endpoints?: IApiEndpointRef[];
}
/**
@ -80,6 +82,8 @@ export interface IUserFormErrors {
name?: string | null;
password?: string | null;
sso_enabled?: boolean | null;
api_endpoints?: string | null;
teams?: string | null;
}
export interface IResetPasswordFormErrors {
new_password?: string | null;
@ -97,7 +101,7 @@ export interface ILoginUserData {
}
export interface ICreateUserFormData {
email: string;
email?: string;
global_role: UserRole | null;
name: string;
password?: string | null;

View file

@ -0,0 +1,108 @@
import React, { useContext, useState } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { ITeam } from "interfaces/team";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import usersAPI from "services/entities/users";
import BackButton from "components/BackButton";
import MainContent from "components/MainContent";
import PageDescription from "components/PageDescription";
import ApiUserForm from "../components/ApiUserForm";
import { IApiUserFormData } from "../components/ApiUserForm/ApiUserForm";
import ApiKeyDisplay from "../components/ApiKeyDisplay";
const baseClass = "create-api-user-page";
interface ICreateApiUserPageProps {
router: InjectedRouter;
}
const CreateApiUserPage = ({ router }: ICreateApiUserPageProps) => {
const { isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [isSubmitting, setIsSubmitting] = useState(false);
const [apiKey, setApiKey] = useState<string | null>(null);
const [createdUserName, setCreatedUserName] = useState("");
const { data: teams } = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
["teams"],
() => teamsAPI.loadAll(),
{
enabled: !!isPremiumTier,
select: (data: ILoadTeamsResponse) => data.teams,
}
);
const handleSubmit = (formData: IApiUserFormData) => {
setIsSubmitting(true);
usersAPI
.createApiOnlyUser({
name: formData.name,
global_role: formData.global_role,
fleets: formData.fleets.map((f) => ({
id: f.id,
role: f.role ?? "observer",
})),
api_endpoints: formData.api_endpoints,
})
.then((response) => {
setCreatedUserName(formData.name);
if (response.token) {
setApiKey(response.token);
} else {
renderFlash(
"warning-filled",
`${formData.name} has been created, but the API key could not be retrieved. Contact your administrator.`
);
router.push(PATHS.ADMIN_USERS);
}
})
.catch(() => {
renderFlash("error", "Could not create user. Please try again.");
})
.finally(() => {
setIsSubmitting(false);
});
};
const handleDone = () => {
renderFlash("success", `${createdUserName} has been created!`);
router.push(PATHS.ADMIN_USERS);
};
return (
<MainContent className={baseClass}>
<BackButton text="Back to users" path={PATHS.ADMIN_USERS} />
{apiKey ? (
<ApiKeyDisplay
newUserName={createdUserName}
apiKey={apiKey}
onDone={handleDone}
/>
) : (
<>
<div>
<h1>New API-only user</h1>
<PageDescription content="This user will have access to the Fleet API, but will not be able to log into the UI." />
</div>
<ApiUserForm
isPremiumTier={isPremiumTier}
onCancel={() => router.push(PATHS.ADMIN_USERS)}
onSubmit={handleSubmit}
availableTeams={teams || []}
isSubmitting={isSubmitting}
/>
</>
)}
</MainContent>
);
};
export default CreateApiUserPage;

View file

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

View file

@ -0,0 +1,146 @@
import React, { useContext, useState } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { IApiError } from "interfaces/errors";
import { ITeam } from "interfaces/team";
import { IUserFormErrors } from "interfaces/user";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import usersAPI from "services/entities/users";
import invitesAPI from "services/entities/invites";
import BackButton from "components/BackButton";
import MainContent from "components/MainContent";
import UserForm from "../components/UserForm";
import { IUserFormData, NewUserType } from "../components/UserForm/UserForm";
const baseClass = "create-user-page";
interface ICreateUserPageProps {
router: InjectedRouter;
}
const CreateUserPage = ({ router }: ICreateUserPageProps) => {
const { config, currentUser, isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [formErrors, setFormErrors] = useState<IUserFormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: teams } = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
["teams"],
() => teamsAPI.loadAll(),
{
enabled: !!isPremiumTier,
select: (data: ILoadTeamsResponse) => data.teams,
}
);
const handleSubmit = (formData: IUserFormData) => {
setIsSubmitting(true);
if (formData.newUserType === NewUserType.AdminInvited) {
const requestData = {
...formData,
invited_by: formData.currentUserId,
};
delete requestData.currentUserId;
delete requestData.newUserType;
delete requestData.password;
invitesAPI
.create(requestData)
.then(() => {
renderFlash("success", `${formData.name} has been invited!`);
router.push(PATHS.ADMIN_USERS);
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setFormErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setFormErrors({
password: "Password must meet the criteria below",
});
} else if (
userErrors.data.errors?.[0].reason.includes("password too long")
) {
setFormErrors({
password: "Password is over the character limit.",
});
} else {
renderFlash("error", "Could not create user. Please try again.");
}
})
.finally(() => {
setIsSubmitting(false);
});
} else {
const requestData = {
...formData,
};
delete requestData.currentUserId;
delete requestData.newUserType;
usersAPI
.createUserWithoutInvitation(requestData)
.then(() => {
renderFlash("success", `${requestData.name} has been created!`);
router.push(PATHS.ADMIN_USERS);
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("Duplicate")) {
setFormErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setFormErrors({
password: "Password must meet the criteria below",
});
} else if (
userErrors.data.errors?.[0].reason.includes("password too long")
) {
setFormErrors({
password: "Password is over the character limit.",
});
} else {
renderFlash("error", "Could not create user. Please try again.");
}
})
.finally(() => {
setIsSubmitting(false);
});
}
};
return (
<MainContent className={baseClass}>
<>
<BackButton text="Back to users" path={PATHS.ADMIN_USERS} />
<h1>New user</h1>
<UserForm
isNewUser
isModifiedByGlobalAdmin
onCancel={() => router.push(PATHS.ADMIN_USERS)}
onSubmit={handleSubmit}
availableTeams={teams || []}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings?.configured || false}
sesConfigured={config?.email?.backend === "ses" || false}
canUseSso={config?.sso_settings?.enable_sso || false}
currentUserId={currentUser?.id}
ancestorErrors={formErrors}
isUpdatingUsers={isSubmitting}
/>
</>
</MainContent>
);
};
export default CreateUserPage;

View file

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

View file

@ -0,0 +1,266 @@
import React, { useContext, useState } from "react";
import { InjectedRouter } from "react-router";
import { useQuery, useQueryClient } from "react-query";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { IApiError } from "interfaces/errors";
import { ITeam } from "interfaces/team";
import { IInvite, IEditInviteFormData } from "interfaces/invite";
import { IUser, IUserFormErrors } from "interfaces/user";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import usersAPI from "services/entities/users";
import invitesAPI from "services/entities/invites";
import BackButton from "components/BackButton";
import MainContent from "components/MainContent";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import UserForm from "../components/UserForm";
import { IUserFormData } from "../components/UserForm/UserForm";
import ApiUserForm from "../components/ApiUserForm";
import { IApiUserFormData } from "../components/ApiUserForm/ApiUserForm";
const baseClass = "edit-user-page";
interface IEditUserPageProps {
router: InjectedRouter;
params: { user_id: string };
location: any; // no type in react-router v3
}
const EditUserPage = ({ router, params, location }: IEditUserPageProps) => {
const entityId = parseInt(params.user_id, 10);
const isInvite = location.query?.type === "invite";
const { config, isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const queryClient = useQueryClient();
const [formErrors, setFormErrors] = useState<IUserFormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Fetch user (when not an invite)
const { data: user, isLoading: isLoadingUser, error: userError } = useQuery<
IUser,
Error
>(["user", entityId], () => usersAPI.getUserById(entityId), {
enabled: !isInvite,
});
// Fetch invite (when editing an invite)
const {
data: invite,
isLoading: isLoadingInvite,
error: inviteError,
} = useQuery<IInvite[], Error, IInvite | undefined>(
["invites", entityId],
() => invitesAPI.loadAll({ globalFilter: "" }),
{
enabled: isInvite,
select: (invites) => invites.find((i) => i.id === entityId),
}
);
const { data: teams, isLoading: isLoadingTeams } = useQuery<
ILoadTeamsResponse,
Error,
ITeam[]
>(["teams"], () => teamsAPI.loadAll(), {
enabled: !!isPremiumTier,
select: (data: ILoadTeamsResponse) => data.teams,
});
const isLoading =
(isInvite ? isLoadingInvite : isLoadingUser) ||
(isPremiumTier && isLoadingTeams);
const hasError = isInvite ? !!inviteError : !!userError;
const entityData = isInvite ? invite : user;
const handleHumanUserSubmit = (formData: IUserFormData) => {
if (!entityData) return;
setIsSubmitting(true);
setFormErrors({});
if (isInvite) {
invitesAPI
.update(entityId, (formData as unknown) as IEditInviteFormData)
.then(() => {
let msg = `Successfully edited ${formData.name}`;
if (entityData.email !== formData.email) {
msg += `. A confirmation email was sent to ${formData.email}.`;
}
renderFlash("success", msg);
router.push(PATHS.ADMIN_USERS);
})
.catch((inviteErrors: { data: IApiError }) => {
if (inviteErrors.data.errors[0].reason.includes("already exists")) {
setFormErrors({
email: "A user with this email address already exists",
});
} else {
renderFlash(
"error",
`Could not edit ${entityData.name}. Please try again.`
);
}
})
.finally(() => {
setIsSubmitting(false);
});
return;
}
// Do not update password to empty string
if (formData.new_password === "") {
formData.new_password = null;
}
// Editing a regular user
const requestData: Record<string, unknown> = {};
if (formData.name !== entityData.name) requestData.name = formData.name;
if (formData.email !== entityData.email) requestData.email = formData.email;
if (formData.sso_enabled !== (entityData as IUser).sso_enabled)
requestData.sso_enabled = formData.sso_enabled;
if (formData.mfa_enabled !== (entityData as IUser).mfa_enabled)
requestData.mfa_enabled = formData.mfa_enabled;
if (formData.global_role !== entityData.global_role)
requestData.global_role = formData.global_role;
if (formData.teams) requestData.teams = formData.teams;
if (formData.new_password) requestData.new_password = formData.new_password;
let successMessage = `Successfully edited ${formData.name}`;
if (entityData.email !== formData.email) {
successMessage += `. A confirmation email was sent to ${formData.email}.`;
}
usersAPI
.update(entityId, requestData)
.then(() => {
queryClient.invalidateQueries(["user", entityId]);
renderFlash("success", successMessage);
router.push(PATHS.ADMIN_USERS);
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setFormErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setFormErrors({
password: "Password must meet the criteria below",
});
} else {
renderFlash(
"error",
`Could not edit ${entityData.name}. Please try again.`
);
}
})
.finally(() => {
setIsSubmitting(false);
});
};
const handleApiUserSubmit = (formData: IApiUserFormData) => {
if (!entityData) return;
setIsSubmitting(true);
setFormErrors({});
usersAPI
.updateApiOnlyUser(entityId, {
name: formData.name,
global_role: formData.global_role,
fleets: formData.fleets.map((f) => ({
id: f.id,
role: f.role ?? "observer",
})),
api_endpoints: formData.api_endpoints,
})
.then(() => {
queryClient.invalidateQueries(["user", entityId]);
renderFlash("success", `Successfully edited ${formData.name}.`);
router.push(PATHS.ADMIN_USERS);
})
.catch(() => {
renderFlash(
"error",
`Could not edit ${entityData.name}. Please try again.`
);
})
.finally(() => {
setIsSubmitting(false);
});
};
if (isLoading) {
return (
<MainContent className={baseClass}>
<Spinner />
</MainContent>
);
}
if (hasError || !entityData) {
return (
<MainContent className={baseClass}>
<BackButton text="Back to users" path={PATHS.ADMIN_USERS} />
<DataError />
</MainContent>
);
}
const showApiForm = !isInvite && (entityData as IUser).api_only;
return (
<MainContent className={baseClass}>
<BackButton text="Back to users" path={PATHS.ADMIN_USERS} />
{showApiForm ? (
<>
<h1>Edit API-only user</h1>
<ApiUserForm
onCancel={() => router.push(PATHS.ADMIN_USERS)}
onSubmit={handleApiUserSubmit}
availableTeams={teams || []}
defaultData={{
name: entityData.name,
global_role: entityData.global_role,
fleets: entityData.teams,
api_endpoints: (entityData as IUser).api_endpoints,
}}
isSubmitting={isSubmitting}
isPremiumTier={isPremiumTier}
/>
</>
) : (
<>
<h1>Edit user</h1>
<UserForm
onCancel={() => router.push(PATHS.ADMIN_USERS)}
onSubmit={handleHumanUserSubmit}
availableTeams={teams || []}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings?.configured || false}
sesConfigured={config?.email?.backend === "ses" || false}
canUseSso={config?.sso_settings?.enable_sso || false}
isSsoEnabled={entityData?.sso_enabled}
isMfaEnabled={(entityData as IUser).mfa_enabled}
isApiOnly={false}
isInvitePending={isInvite}
isModifiedByGlobalAdmin
defaultName={entityData?.name}
defaultEmail={entityData?.email}
defaultGlobalRole={entityData?.global_role}
defaultTeams={entityData?.teams}
ancestorErrors={formErrors}
isUpdatingUsers={isSubmitting}
/>
</>
)}
</MainContent>
);
};
export default EditUserPage;

View file

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

View file

@ -105,4 +105,189 @@
}
}
}
// Override react-select container positioning so the brand-button
// dropdown menu anchors to the wrapper instead of the collapsed control.
// The .add-user-dropdown className is applied to the react-select container
// element inside .actions-dropdown__wrapper.
.actions-dropdown__wrapper:has(.add-user-dropdown) {
position: relative;
// Make react-select container static so the absolutely positioned
// menu anchors to the wrapper above
.add-user-dropdown {
position: static;
}
}
.add-user-dropdown {
.actions-dropdown-select__menu {
margin-top: 4px !important; // Override inline 20px from brand-button variant
}
.actions-dropdown__option {
display: flex;
flex-direction: column;
}
.actions-dropdown__help-text {
@include help-text;
}
}
}
.create-user-page,
.create-api-user-page,
.edit-user-page {
@include vertical-page-layout;
h1 {
margin: 0;
}
.back-button {
align-self: flex-start;
}
.page-description {
margin-top: 8px;
}
// Left-align form footers on page-based flows (both UserForm's
// ModalFooter and ApiUserForm's shared footer class)
.modal-footer__content-wrapper,
.user-management-form__footer {
display: flex;
gap: $pad-medium;
padding-top: $pad-medium;
align-self: flex-start;
}
}
.api-key-display {
display: flex;
flex-direction: column;
&__api-key-label {
color: $core-fleet-black;
font-size: $small;
margin-bottom: $pad-large;
}
.input-field-hidden-content {
margin-bottom: $pad-medium;
}
.info-banner {
margin-bottom: $pad-medium;
}
}
.api-access-section {
@include vertical-page-layout;
&__endpoint-selector {
display: flex;
flex-direction: column;
gap: $pad-small;
}
}
.endpoint-selector-table {
position: relative;
&__name-cell {
display: inline-flex;
align-items: center;
gap: $pad-small;
}
&__search-dropdown {
width: 100%;
position: absolute;
top: 44px;
left: 0;
z-index: 1;
background-color: $core-fleet-white;
border-radius: 0 0 8px 8px;
.table-container {
box-shadow: 0px 4px 10px rgba(52, 59, 96, 0.15);
border-radius: 0 0 8px 8px;
&__header {
display: none;
}
.data-table__wrapper {
position: relative;
width: 100%;
max-height: 300px;
overflow: auto;
margin: 0;
}
}
.empty-search {
padding: $pad-xlarge;
display: flex;
justify-content: center;
box-shadow: 0px 4px 10px rgba(52, 59, 96, 0.15);
&__inner {
display: flex;
flex-direction: column;
align-items: center;
}
h4 {
margin: 0 0 $pad-small;
font-size: $small;
}
p {
margin: 0;
font-size: $x-small;
text-align: center;
}
}
}
&__selected-table {
margin-top: $pad-medium;
.table-container {
background-color: $core-fleet-white;
&__header {
display: none;
}
.data-table__wrapper {
position: relative;
margin: 0;
}
}
.data-table__table {
table-layout: fixed;
}
.method__header,
.method__cell {
width: 100px;
}
.path__cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete__header,
.delete__cell {
width: 40px;
text-align: center;
}
}
}

View file

@ -0,0 +1,84 @@
import React, { useCallback } from "react";
import { IApiEndpointRef } from "interfaces/api_endpoint";
import Radio from "components/forms/fields/Radio";
import TooltipWrapper from "components/TooltipWrapper";
import ApiEndpointSelectorTable from "../ApiEndpointSelectorTable";
const baseClass = "api-access-section";
enum ApiAccessType {
AllEndpoints = "ALL_ENDPOINTS",
SpecificEndpoints = "SPECIFIC_ENDPOINTS",
}
interface IApiAccessSectionProps {
isSpecificEndpoints: boolean;
onAccessTypeChange: (isSpecific: boolean) => void;
selectedEndpoints: IApiEndpointRef[];
onEndpointSelectionChange: (endpoints: IApiEndpointRef[]) => void;
error?: string | null;
}
const ApiAccessSection = ({
isSpecificEndpoints,
onAccessTypeChange,
selectedEndpoints,
onEndpointSelectionChange,
error,
}: IApiAccessSectionProps) => {
const handleAccessTypeChange = useCallback(
(value: string) => {
onAccessTypeChange(value === ApiAccessType.SpecificEndpoints);
},
[onAccessTypeChange]
);
return (
<div className={baseClass}>
<div className={`${baseClass}__access-type-field form-field`}>
<div className="form-field__label">API access</div>
<Radio
className={`${baseClass}__radio-input`}
label="All API endpoints"
id="all-endpoints"
checked={!isSpecificEndpoints}
value={ApiAccessType.AllEndpoints}
name="api-access-type"
onChange={handleAccessTypeChange}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Specific API endpoints"
id="specific-endpoints"
checked={isSpecificEndpoints}
value={ApiAccessType.SpecificEndpoints}
name="api-access-type"
onChange={handleAccessTypeChange}
/>
</div>
{isSpecificEndpoints && (
<div className={`${baseClass}__endpoint-selector`}>
<div className="form-field">
<div className="form-field__label">
<TooltipWrapper tipContent="Specifying endpoints can narrow down a user's API access, but will not grant additional permissions otherwise forbidden by their role.">
Select API endpoints
</TooltipWrapper>
</div>
</div>
<ApiEndpointSelectorTable
selectedEndpoints={selectedEndpoints}
onSelectionChange={onEndpointSelectionChange}
/>
{error && (
<div className="form-field__label form-field__label--error">
{error}
</div>
)}
</div>
)}
</div>
);
};
export default ApiAccessSection;

View file

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

View file

@ -0,0 +1,272 @@
import React, {
useMemo,
useState,
useCallback,
useRef,
useEffect,
} from "react";
import { useQuery } from "react-query";
import { Row } from "react-table";
import { isEmpty } from "lodash";
import TableContainer from "components/TableContainer";
import TextCell from "components/TableContainer/DataTable/TextCell/TextCell";
import PillBadge from "components/PillBadge";
import Button from "components/buttons/Button";
import Icon from "components/Icon/Icon";
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon/InputFieldWithIcon";
import DataError from "components/DataError";
import CustomLink from "components/CustomLink";
import {
IApiEndpoint,
IApiEndpointRef,
endpointKey,
} from "interfaces/api_endpoint";
import apiEndpointsAPI from "services/entities/api_endpoints";
const baseClass = "endpoint-selector-table";
interface IApiEndpointRow extends IApiEndpoint {
id: string;
}
/** Normalize path parameter names (e.g. `:id`, `:host_id` `:_`) so search
* ignores parameter naming differences. */
const normalizePath = (s: string) =>
s.toLowerCase().replace(/:[a-z0-9_]+/g, ":_");
interface IApiEndpointSelectorTableProps {
selectedEndpoints: IApiEndpointRef[];
onSelectionChange: (endpoints: IApiEndpointRef[]) => void;
}
interface ICellProps {
cell: { value: string };
row: { original: IApiEndpointRow };
}
const NameCell = (cellProps: ICellProps) => {
const { deprecated } = cellProps.row.original;
return (
<span className={`${baseClass}__name-cell`}>
<TextCell value={cellProps.cell.value} className="" />
{deprecated && (
<PillBadge tipContent="This endpoint is deprecated and may be removed in a future version.">
Deprecated
</PillBadge>
)}
</span>
);
};
const searchResultsTableHeaders = [
{
title: "Name",
Header: "Name",
accessor: "display_name",
disableSortBy: true,
Cell: NameCell,
},
{
title: "Method",
Header: "Method",
accessor: "method",
disableSortBy: true,
Cell: (cellProps: ICellProps) => <code>{cellProps.cell.value}</code>,
},
{
title: "Path",
Header: "Path",
accessor: "path",
disableSortBy: true,
Cell: (cellProps: ICellProps) => <code>{cellProps.cell.value}</code>,
},
];
const generateSelectedTableHeaders = (
handleRemove: (row: Row<IApiEndpointRow>) => void
) => [
...searchResultsTableHeaders,
{
id: "delete",
Header: "",
Cell: (cellProps: { row: Row<IApiEndpointRow> }) => (
<Button onClick={() => handleRemove(cellProps.row)} variant="icon">
<Icon name="close-filled" />
</Button>
),
disableHidden: true,
},
];
const ApiEndpointSelectorTable = ({
selectedEndpoints,
onSelectionChange,
}: IApiEndpointSelectorTableProps) => {
const [searchText, setSearchText] = useState("");
const dropdownRef = useRef<HTMLDivElement | null>(null);
const { data: apiEndpoints, isLoading, error } = useQuery<
IApiEndpoint[],
Error
>(["api_endpoints"], () => apiEndpointsAPI.loadAll(), {
refetchOnWindowFocus: false,
});
const allRows: IApiEndpointRow[] = useMemo(
() =>
(apiEndpoints || []).map((ep) => ({
...ep,
id: endpointKey(ep),
})),
[apiEndpoints]
);
// Filter search results: match search text and exclude already-selected.
// Path parameter names (e.g. `:id`, `:host_id`) are normalized so searching
// "/hosts/:id/report" matches "/hosts/:host_id/report".
const searchResults: IApiEndpointRow[] = useMemo(() => {
if (isEmpty(searchText)) return [];
const query = normalizePath(searchText);
return allRows.filter((ep) => {
if (selectedEndpoints.some((s) => endpointKey(s) === ep.id)) return false;
return (
ep.display_name.toLowerCase().includes(query) ||
normalizePath(ep.path).includes(query) ||
ep.method.toLowerCase().includes(query)
);
});
}, [allRows, searchText, selectedEndpoints]);
const selectedRows: IApiEndpointRow[] = useMemo(
() =>
allRows.filter((ep) =>
selectedEndpoints.some((s) => endpointKey(s) === ep.id)
),
[allRows, selectedEndpoints]
);
// Close dropdown when clicking outside or pressing Escape
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setSearchText("");
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setSearchText("");
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
const handleRowSelect = useCallback(
(row: Row<IApiEndpointRow>) => {
const { method, path } = row.original;
if (
!selectedEndpoints.some((s) => s.method === method && s.path === path)
) {
onSelectionChange([...selectedEndpoints, { method, path }]);
}
setSearchText("");
},
[selectedEndpoints, onSelectionChange]
);
const handleRowRemove = useCallback(
(row: Row<IApiEndpointRow>) => {
const { method, path } = row.original;
onSelectionChange(
selectedEndpoints.filter((s) => s.method !== method || s.path !== path)
);
},
[selectedEndpoints, onSelectionChange]
);
const selectedTableHeaders = useMemo(
() => generateSelectedTableHeaders(handleRowRemove),
[handleRowRemove]
);
const isDropdownOpen = !isEmpty(searchText);
const showResults = isDropdownOpen && !error;
const showSearchError = isDropdownOpen && !!error;
return (
<div className={baseClass}>
<InputFieldWithIcon
type="search"
iconSvg="search"
value={searchText}
placeholder="Search by name or path"
onChange={setSearchText}
/>
<span className="form-field__help-text">
You can find this information in the{" "}
<CustomLink
url="https://fleetdm.com/docs/rest-api/rest-api"
text="REST API docs"
newTab
/>
</span>
{showResults && (
<div className={`${baseClass}__search-dropdown`} ref={dropdownRef}>
<TableContainer<Row<IApiEndpointRow>>
columnConfigs={searchResultsTableHeaders}
data={searchResults}
isLoading={isLoading}
emptyComponent={() => (
<div className="empty-search">
<div className="empty-search__inner">
<h4>No matching API endpoints.</h4>
<p>
Please check the API documentation and try again.
<br />
Experimental endpoints are not supported.
</p>
</div>
</div>
)}
showMarkAllPages={false}
isAllPagesSelected={false}
disableCount
disableMultiRowSelect
isClientSidePagination
pageSize={10}
onClickRow={handleRowSelect}
/>
</div>
)}
{showSearchError && (
<div className={`${baseClass}__search-dropdown`} ref={dropdownRef}>
<DataError />
</div>
)}
{selectedRows.length > 0 && (
<div className={`${baseClass}__selected-table`}>
<TableContainer
columnConfigs={selectedTableHeaders}
data={selectedRows}
isLoading={false}
showMarkAllPages={false}
isAllPagesSelected={false}
disableCount
disablePagination
emptyComponent={() => <></>}
/>
</div>
)}
</div>
);
};
export default ApiEndpointSelectorTable;

View file

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

View file

@ -0,0 +1,40 @@
import React from "react";
import InfoBanner from "components/InfoBanner/InfoBanner";
import InputFieldHiddenContent from "components/forms/fields/InputFieldHiddenContent";
import Button from "components/buttons/Button";
const baseClass = "api-key-display";
interface IApiKeyDisplayProps {
newUserName: string;
apiKey: string;
onDone: () => void;
}
const ApiKeyDisplay = ({
newUserName,
apiKey,
onDone,
}: IApiKeyDisplayProps) => {
return (
<>
<h1>{newUserName}</h1>
<div className={baseClass}>
<div className={`${baseClass}__api-key-label`}>
<b>API Key</b>
</div>
<InputFieldHiddenContent value={apiKey} name="api-key" />
<InfoBanner color="yellow">
Please make a note of this API key since it is the only time you will
be able to view it.
</InfoBanner>
<div>
<Button onClick={onDone}>Done</Button>
</div>
</div>
</>
);
};
export default ApiKeyDisplay;

View file

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

View file

@ -0,0 +1,275 @@
import React, { FormEvent, useState } from "react";
import { IApiEndpointRef } from "interfaces/api_endpoint";
import { ITeam } from "interfaces/team";
import { IUserFormErrors, UserRole } from "interfaces/user";
import { SingleValue } from "react-select-5";
import Button from "components/buttons/Button";
import DropdownWrapper from "components/forms/fields/DropdownWrapper";
import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper";
import validatePresence from "components/forms/validators/validate_presence";
import InputField from "components/forms/fields/InputField";
import Radio from "components/forms/fields/Radio";
import SelectedTeamsForm from "../SelectedTeamsForm/SelectedTeamsForm";
import ApiAccessSection from "../ApiAccessSection";
import { roleOptions } from "../../helpers/userManagementHelpers";
export interface IApiUserFormData {
name: string;
global_role: UserRole | null;
fleets: ITeam[];
api_endpoints?: IApiEndpointRef[] | null;
}
interface IApiUserFormProps {
onCancel: () => void;
onSubmit: (formData: IApiUserFormData) => void;
availableTeams: ITeam[];
defaultData?: IApiUserFormData;
isSubmitting?: boolean;
isPremiumTier?: boolean;
}
enum UserTeamType {
GlobalUser = "GLOBAL_USER",
AssignTeams = "ASSIGN_TEAMS",
}
const ApiUserForm = ({
isPremiumTier,
onCancel,
onSubmit,
availableTeams,
defaultData,
isSubmitting = false,
}: IApiUserFormProps) => {
const isNewUser = defaultData === undefined;
const [name, setName] = useState(defaultData?.name ?? "");
const [globalRole, setGlobalRole] = useState<UserRole>(
() =>
(defaultData?.global_role ??
(isPremiumTier ? "gitops" : "observer")) as UserRole
);
const [fleets, setFleets] = useState<ITeam[]>(defaultData?.fleets ?? []);
const [isGlobalUser, setIsGlobalUser] = useState(
!defaultData?.fleets?.length
);
const [selectedEndpoints, setSelectedEndpoints] = useState<IApiEndpointRef[]>(
() => defaultData?.api_endpoints ?? []
);
// null (all endpoints) and undefined (field not set / free tier) are both treated as "all endpoints"
const [isSpecificEndpoints, setIsSpecificEndpoints] = useState(
() => !!defaultData?.api_endpoints && defaultData.api_endpoints.length > 0
);
const [formErrors, setFormErrors] = useState<IUserFormErrors>({});
const clearEndpointError = () => {
if (formErrors.api_endpoints) {
setFormErrors((prev) => {
const { api_endpoints: _, ...rest } = prev;
return rest;
});
}
};
const handleEndpointSelectionChange = (endpoints: IApiEndpointRef[]) => {
setSelectedEndpoints(endpoints);
if (endpoints.length > 0) {
clearEndpointError();
}
};
const handleAccessTypeChange = (specific: boolean) => {
setIsSpecificEndpoints(specific);
if (!specific) {
setSelectedEndpoints([]);
clearEndpointError();
}
};
const getErrors = (): IUserFormErrors => {
const errors: IUserFormErrors = {};
if (!validatePresence(name)) {
errors.name = "Name is required";
}
if (!isGlobalUser && fleets.length === 0) {
errors.teams = "Please select at least one fleet";
}
if (isSpecificEndpoints && selectedEndpoints.length === 0) {
errors.api_endpoints = "Please select at least one API endpoint";
}
return errors;
};
const onInputChange = (value: string) => {
setName(value);
if (formErrors.name && validatePresence(value)) {
setFormErrors((prev) => {
const { name: _n, ...rest } = prev;
return rest;
});
}
};
const onInputBlur = () => {
if (!validatePresence(name)) {
setFormErrors((prev) => ({ ...prev, name: "Name is required" }));
}
};
const handleSubmit = (evt: FormEvent) => {
evt.preventDefault();
const errs = getErrors();
if (Object.keys(errs).length > 0) {
setFormErrors(errs);
return;
}
// Omit api_endpoints on free tier to avoid clearing a value set by a premium instance.
// When "All" is selected, send null to signal full access.
let apiEndpoints: IApiEndpointRef[] | null | undefined;
if (isPremiumTier) {
apiEndpoints = isSpecificEndpoints ? selectedEndpoints : null;
}
onSubmit({
name,
global_role: isGlobalUser ? globalRole : null,
fleets: isGlobalUser
? []
: fleets.map((f) => ({ ...f, role: f.role || "observer" })),
api_endpoints: apiEndpoints,
});
};
const handleRoleChange = (newValue: SingleValue<CustomOptionType>) => {
if (newValue) {
setGlobalRole(newValue.value as UserRole);
}
};
const handleFleetChange = (newFleets: ITeam[]) => {
setFleets(newFleets);
if (newFleets.length > 0 && formErrors.teams) {
setFormErrors((prev) => {
const { teams: _, ...rest } = prev;
return rest;
});
}
};
const handleIsGlobalUserChange = (value: string) => {
const isGlobal = value === UserTeamType.GlobalUser;
setIsGlobalUser(isGlobal);
if (isGlobal && formErrors.teams) {
setFormErrors((prev) => {
const { teams: _, ...rest } = prev;
return rest;
});
}
};
const renderGlobalRoleForm = () => (
<DropdownWrapper
name="Role"
label="Role"
value={globalRole}
options={roleOptions({ isPremiumTier, isApiOnly: true })}
onChange={handleRoleChange}
isSearchable={false}
/>
);
const renderTeamsForm = () => (
<SelectedTeamsForm
availableTeams={availableTeams}
usersCurrentTeams={fleets}
onFormChange={handleFleetChange}
isApiOnly
/>
);
const renderPermissions = () => (
<>
<div className="form-field team-field">
<div className="form-field__label">Permissions</div>
<Radio
label="Global user"
id="global-user"
checked={isGlobalUser}
value={UserTeamType.GlobalUser}
name="user-team-type"
onChange={handleIsGlobalUserChange}
/>
<Radio
label="Assign to fleet(s)"
id="assign-teams"
checked={!isGlobalUser}
value={UserTeamType.AssignTeams}
name="user-team-type"
onChange={handleIsGlobalUserChange}
disabled={!availableTeams.length}
/>
</div>
{isGlobalUser ? (
renderGlobalRoleForm()
) : (
<>
{renderTeamsForm()}
{formErrors.teams && (
<div className="form-field__label form-field__label--error">
{formErrors.teams}
</div>
)}
</>
)}
</>
);
return (
<>
<div>
<form autoComplete="off" onSubmit={handleSubmit}>
<InputField
name="name"
label="Name"
value={name}
onChange={onInputChange}
onBlur={onInputBlur}
error={formErrors.name}
autofocus
/>
{isPremiumTier ? renderPermissions() : renderGlobalRoleForm()}
{isPremiumTier && (
<ApiAccessSection
isSpecificEndpoints={isSpecificEndpoints}
onAccessTypeChange={handleAccessTypeChange}
selectedEndpoints={selectedEndpoints}
onEndpointSelectionChange={handleEndpointSelectionChange}
error={formErrors.api_endpoints}
/>
)}
<div className="user-management-form__footer">
<Button onClick={onCancel} variant="inverse">
Cancel
</Button>
<Button
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{isNewUser ? "Add" : "Save"}
</Button>
</div>
</form>
</div>
</>
);
};
export default ApiUserForm;

View file

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

View file

@ -116,11 +116,12 @@ const SelectedTeamsForm = ({
);
return (
<div className={`${baseClass} form`}>
<ul className={`${baseClass}__list`}>
<li className={`${baseClass}__header`}>Fleets</li>
{teamsFormList.map((teamItem) => {
const { isChecked, name, role, id } = teamItem;
return (
<div key={id} className={`${baseClass}__team-item`}>
<li key={id} className={`${baseClass}__team-item`}>
<Checkbox
value={isChecked}
name={name}
@ -130,21 +131,23 @@ const SelectedTeamsForm = ({
>
{name}
</Checkbox>
<DropdownWrapper
name={name}
value={role}
className={`${baseClass}__role-dropdown`}
options={roleOptions({ isPremiumTier: true, isApiOnly })}
isSearchable={false}
onChange={(newValue: SingleValue<CustomOptionType>) =>
updateSelectedTeams(teamItem.id, newValue as CustomOptionType)
}
onMenuOpen={onMenuOpen}
/>
</div>
{isChecked && (
<DropdownWrapper
name={name}
value={role}
className={`${baseClass}__role-dropdown`}
options={roleOptions({ isPremiumTier: true, isApiOnly })}
isSearchable={false}
onChange={(newValue: SingleValue<CustomOptionType>) =>
updateSelectedTeams(teamItem.id, newValue as CustomOptionType)
}
onMenuOpen={onMenuOpen}
/>
)}
</li>
);
})}
</div>
</ul>
);
};

View file

@ -1,19 +1,39 @@
.selected-teams-form {
border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius;
background-color: $ui-off-white;
padding: $pad-medium;
box-sizing: border-box;
// custom form field gap
gap: 0.25rem;
&__list {
display: flex;
flex-direction: column;
border-radius: 4px;
border: 1px solid $ui-fleet-black-10;
padding-left: 0;
margin: 0;
list-style: none;
}
&__header {
display: flex;
align-items: center;
height: 38px;
padding: 0 $pad-medium;
font-size: $x-small;
font-weight: $bold;
background-color: $ui-off-white;
border-bottom: 1px solid $ui-fleet-black-10;
}
&__team-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: $pad-xsmall $pad-medium;
min-height: 38px; // matches react-select control height
border-bottom: 1px solid $ui-fleet-black-10;
&:last-child {
border-bottom: none;
}
.form-field--dropdown {
width: 170px; // Fits content
width: 170px;
}
}

View file

@ -2,16 +2,14 @@ import React, { useState, useCallback, useContext, useMemo } from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import paths from "router/paths";
import { IApiError } from "interfaces/errors";
import { IInvite, IEditInviteFormData } from "interfaces/invite";
import { IUser, IUserFormErrors } from "interfaces/user";
import { ITeam } from "interfaces/team";
import PATHS from "router/paths";
import { IInvite } from "interfaces/invite";
import { IUser } from "interfaces/user";
import { IDropdownOption } from "interfaces/dropdownOption";
import authToken from "utilities/auth_token";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams";
import usersAPI from "services/entities/users";
import invitesAPI from "services/entities/invites";
@ -20,6 +18,7 @@ import { ITableQueryData } from "components/TableContainer/TableContainer";
import TableCount from "components/TableContainer/TableCount";
import TableDataError from "components/DataError";
import EmptyTable from "components/EmptyTable";
import ActionsDropdown from "components/ActionsDropdown";
import {
generateTableHeaders,
combineDataSets,
@ -28,9 +27,19 @@ import {
import DeleteUserModal from "../DeleteUserModal";
import ResetPasswordModal from "../ResetPasswordModal";
import ResetSessionsModal from "../ResetSessionsModal";
import { NewUserType, IUserFormData } from "../UserForm/UserForm";
import AddUserModal from "../AddUserModal";
import EditUserModal from "../EditUserModal";
const ADD_USER_OPTIONS: IDropdownOption[] = [
{
label: "Regular user",
value: "human",
helpText: "A human with access to Fleet",
},
{
label: "API-only user",
value: "api",
helpText: "For GitOps or Fleet API automations",
},
];
const EmptyUsersTable = () => (
<EmptyTable
@ -43,35 +52,18 @@ interface IUsersTableProps {
router: InjectedRouter; // v3
}
const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
const { config, currentUser, isPremiumTier } = useContext(AppContext);
const { currentUser, isPremiumTier } = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
// STATES
const [showAddUserModal, setShowAddUserModal] = useState(false);
const [showEditUserModal, setShowEditUserModal] = useState(false);
const [showDeleteUserModal, setShowDeleteUserModal] = useState(false);
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false);
const [showResetSessionsModal, setShowResetSessionsModal] = useState(false);
const [isUpdatingUsers, setIsUpdatingUsers] = useState(false);
const [userEditing, setUserEditing] = useState<IUserTableData | null>(null);
const [addUserErrors, setAddUserErrors] = useState<IUserFormErrors>({});
const [editUserErrors, setEditUserErrors] = useState<IUserFormErrors>({});
const [querySearchText, setQuerySearchText] = useState("");
// API CALLS
const {
data: teams,
isFetching: isFetchingTeams,
error: loadingTeamsError,
} = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
["teams"],
() => teamsAPI.loadAll(),
{
enabled: !!isPremiumTier,
select: (data: ILoadTeamsResponse) => data.teams,
}
);
const {
data: users,
isFetching: isFetchingUsers,
@ -100,21 +92,8 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
}
);
// TODO: Cleanup useCallbacks, add missing dependencies, use state setter functions, e.g.,
// `setShowAddUserModal((prevState) => !prevState)`, instead of including state
// variables as dependencies for toggles, etc.
// TOGGLE MODALS
const toggleAddUserModal = useCallback(() => {
setShowAddUserModal(!showAddUserModal);
// clear errors on close
if (!showAddUserModal) {
setAddUserErrors({});
}
}, [showAddUserModal, setShowAddUserModal]);
const toggleDeleteUserModal = useCallback(
(user?: IUserTableData) => {
setShowDeleteUserModal(!showDeleteUserModal);
@ -123,15 +102,6 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
[showDeleteUserModal, setShowDeleteUserModal, setUserEditing]
);
const toggleEditUserModal = useCallback(
(user?: IUserTableData) => {
setShowEditUserModal(!showEditUserModal);
setUserEditing(!showEditUserModal ? user ?? null : null);
setEditUserErrors({});
},
[showEditUserModal, setShowEditUserModal, setUserEditing]
);
const toggleResetPasswordUserModal = useCallback(
(user?: IUserTableData) => {
setShowResetPasswordModal(!showResetPasswordModal);
@ -150,17 +120,16 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
// FUNCTIONS
const goToAccountPage = useCallback(() => {
const { ACCOUNT } = paths;
router.push(ACCOUNT);
}, [router]);
const onActionSelect = useCallback(
(value: string, user: IUserTableData) => {
switch (value) {
case "edit":
toggleEditUserModal(user);
case "edit": {
const editPath = PATHS.ADMIN_USERS_EDIT(user.apiId);
router.push(
user.type === "invite" ? `${editPath}?type=invite` : editPath
);
break;
}
case "delete":
toggleDeleteUserModal(user);
break;
@ -171,7 +140,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
toggleResetSessionsUserModal(user);
break;
case "editMyAccount":
goToAccountPage();
router.push(PATHS.ACCOUNT);
break;
default:
return null;
@ -179,11 +148,10 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
return null;
},
[
toggleEditUserModal,
router,
toggleDeleteUserModal,
toggleResetPasswordUserModal,
toggleResetSessionsUserModal,
goToAccountPage,
]
);
@ -199,177 +167,6 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
[refetchUsers, refetchInvites]
);
const getUser = (type: string, id: number) => {
let userData;
if (type === "user") {
userData = users?.find((user) => user.id === id);
} else {
userData = invites?.find((invite) => invite.id === id);
}
return userData;
};
const onAddUserSubmit = (formData: IUserFormData) => {
setIsUpdatingUsers(true);
if (formData.newUserType === NewUserType.AdminInvited) {
// Do some data formatting adding `invited_by` for the request to be correct and deleteing uncessary fields
const requestData = {
...formData,
invited_by: formData.currentUserId,
};
delete requestData.currentUserId; // this field is not needed for the request
delete requestData.newUserType; // this field is not needed for the request
delete requestData.password; // this field is not needed for the request
invitesAPI
.create(requestData)
.then(() => {
renderFlash("success", `${formData.name} has been invited!`);
toggleAddUserModal();
refetchInvites();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setAddUserErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setAddUserErrors({
password: "Password must meet the criteria below",
});
} else if (
userErrors.data.errors?.[0].reason.includes("password too long")
) {
setAddUserErrors({
password: "Password is over the character limit.",
});
} else {
renderFlash("error", "Could not create user. Please try again.");
}
})
.finally(() => {
setIsUpdatingUsers(false);
});
} else {
// Do some data formatting deleting unnecessary fields
const requestData = {
...formData,
};
delete requestData.currentUserId; // this field is not needed for the request
delete requestData.newUserType; // this field is not needed for the request
usersAPI
.createUserWithoutInvitation(requestData)
.then(() => {
renderFlash("success", `${requestData.name} has been created!`);
toggleAddUserModal();
refetchUsers();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("Duplicate")) {
setAddUserErrors({
email: "A user with this email address already exists",
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setAddUserErrors({
password: "Password must meet the criteria below",
});
} else if (
userErrors.data.errors?.[0].reason.includes("password too long")
) {
setAddUserErrors({
password: "Password is over the character limit.",
});
} else {
renderFlash("error", "Could not create user. Please try again.");
}
})
.finally(() => {
setIsUpdatingUsers(false);
});
}
};
const onEditUser = (formData: IUserFormData) => {
if (!userEditing) return;
const userData = getUser(userEditing.type, userEditing.apiId);
let userUpdatedFlashMessage = `Successfully edited ${formData.name}`;
if (userData?.email !== formData.email) {
userUpdatedFlashMessage += `. A confirmation email was sent to ${formData.email}.`;
}
const userUpdatedEmailError =
"A user with this email address already exists";
const userUpdatedPasswordError = "Password must meet the criteria below";
const userUpdatedError = `Could not edit ${userEditing?.name}. Please try again.`;
// Do not update password to empty string
const requestData = formData;
if (requestData.new_password === "") {
requestData.new_password = null;
}
if (!userData) return;
setIsUpdatingUsers(true);
if (userEditing.type === "invite") {
invitesAPI
.update(userData.id, requestData as IEditInviteFormData)
.then(() => {
renderFlash("success", userUpdatedFlashMessage);
toggleEditUserModal();
refetchInvites();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setEditUserErrors({
email: userUpdatedEmailError,
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setEditUserErrors({
password: userUpdatedPasswordError,
});
} else {
renderFlash("error", userUpdatedError);
}
})
.finally(() => {
setIsUpdatingUsers(false);
});
} else {
usersAPI
.update(userData.id, requestData)
.then(() => {
renderFlash("success", userUpdatedFlashMessage);
toggleEditUserModal();
refetchUsers();
})
.catch((userErrors: { data: IApiError }) => {
if (userErrors.data.errors[0].reason.includes("already exists")) {
setEditUserErrors({
email: userUpdatedEmailError,
});
} else if (
userErrors.data.errors[0].reason.includes("required criteria")
) {
setEditUserErrors({
password: userUpdatedPasswordError,
});
} else {
renderFlash("error", userUpdatedError);
}
})
.finally(() => {
setIsUpdatingUsers(false);
});
}
};
const onDeleteUser = () => {
if (!userEditing) return;
setIsUpdatingUsers(true);
@ -453,53 +250,6 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
});
};
const renderEditUserModal = () => {
if (!userEditing) return null;
const userData = getUser(userEditing.type, userEditing.apiId);
return (
<EditUserModal
defaultEmail={userData?.email}
defaultName={userData?.name}
defaultGlobalRole={userData?.global_role}
defaultTeams={userData?.teams}
onCancel={toggleEditUserModal}
onSubmit={onEditUser}
availableTeams={teams || []}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings?.configured || false}
sesConfigured={config?.email?.backend === "ses" || false}
canUseSso={config?.sso_settings?.enable_sso || false}
isSsoEnabled={userData?.sso_enabled}
isMfaEnabled={userData?.mfa_enabled}
isApiOnly={userData?.api_only || false}
isModifiedByGlobalAdmin
isInvitePending={userEditing.type === "invite"}
editUserErrors={editUserErrors}
isUpdatingUsers={isUpdatingUsers}
/>
);
};
const renderAddUserModal = () => {
return (
<AddUserModal
addUserErrors={addUserErrors}
onCancel={toggleAddUserModal}
onSubmit={onAddUserSubmit}
availableTeams={teams || []}
defaultGlobalRole="observer"
defaultTeams={[]}
isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings?.configured || false}
sesConfigured={config?.email?.backend === "ses" || false}
canUseSso={config?.sso_settings?.enable_sso || false}
isUpdatingUsers={isUpdatingUsers}
isModifiedByGlobalAdmin
/>
);
};
const renderDeleteUserModal = () => {
if (!userEditing) return null;
return (
@ -535,10 +285,8 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
[onActionSelect, isPremiumTier]
);
const loadingTableData =
isFetchingUsers || isFetchingInvites || isFetchingTeams;
const tableDataError =
loadingUsersError || loadingInvitesError || loadingTeamsError;
const loadingTableData = isFetchingUsers || isFetchingInvites;
const tableDataError = loadingUsersError || loadingInvitesError;
const tableData = useMemo(
() =>
@ -556,6 +304,32 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
return <TableCount name="users" count={tableData?.length} />;
}, [tableData?.length]);
const onAddUserSelect = useCallback(
(value: string) => {
if (value === "human") {
router.push(PATHS.ADMIN_USERS_NEW_HUMAN);
} else if (value === "api") {
router.push(PATHS.ADMIN_USERS_NEW_API);
}
},
[router]
);
const renderAddUserControl = useCallback(
() => (
<ActionsDropdown
options={ADD_USER_OPTIONS}
onChange={onAddUserSelect}
placeholder="Add user"
variant="brand-button"
buttonLabel="Add user"
className="add-user-dropdown"
menuAlign="left"
/>
),
[onAddUserSelect]
);
return (
<>
{tableDataError ? (
@ -568,11 +342,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
defaultSortHeader="name"
defaultSortDirection="asc"
inputPlaceHolder="Search by name or email"
actionButton={{
name: "add user",
buttonText: "Add user",
onClick: toggleAddUserModal,
}}
customControl={renderAddUserControl}
onQueryChange={onTableQueryChange}
resultsTitle="users"
emptyComponent={EmptyUsersTable}
@ -583,8 +353,6 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
renderCount={renderUsersCount}
/>
)}
{showAddUserModal && renderAddUserModal()}
{showEditUserModal && renderEditUserModal()}
{showDeleteUserModal && renderDeleteUserModal()}
{showResetSessionsModal && renderResetSessionsModal()}
{showResetPasswordModal && renderResetPasswordModal()}

View file

@ -5,14 +5,18 @@ import StatusIndicator from "components/StatusIndicator";
import TextCell from "components/TableContainer/DataTable/TextCell/TextCell";
import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell";
import TooltipWrapper from "components/TooltipWrapper";
import PillBadge from "components/PillBadge";
import { IInvite } from "interfaces/invite";
import { IUser, UserRole } from "interfaces/user";
import { IDropdownOption } from "interfaces/dropdownOption";
import { generateRole, generateTeam, greyCell } from "utilities/helpers";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import { renderApiUserIndicator } from "pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig";
import ActionsDropdown from "../../../../../components/ActionsDropdown";
const renderApiUserIndicator = () => {
return <PillBadge tipContent="This user only has API access.">API</PillBadge>;
};
interface IHeaderProps {
column: {
title: string;
@ -101,11 +105,9 @@ const generateTableHeaders = (
<TooltipWrapper
tipContent={
<>
The GitOps role is only available on the command-line
The GitOps role is only available for API-only
<br />
when creating an API-only user. This user has no
<br />
access to the UI.
users. This user has no access to the UI.
</>
}
>
@ -158,9 +160,20 @@ const generateTableHeaders = (
Header: "Email",
disableSortBy: true,
accessor: "email",
Cell: (cellProps: ICellProps) => (
<TextCell value={cellProps.cell.value} />
),
Cell: (cellProps: ICellProps) => {
const isApiOnly = cellProps.row.original.api_only;
if (isApiOnly) {
return (
<TooltipWrapper
tipContent="API-only users do not receive emails or log into the UI."
underline={false}
>
---
</TooltipWrapper>
);
}
return <TextCell value={cellProps.cell.value} />;
},
},
{
title: "Actions",
@ -209,7 +222,8 @@ const generateStatus = (type: string, data: IUser | IInvite): string => {
const generateActionDropdownOptions = (
isCurrentUser: boolean,
isInvitePending: boolean,
isSsoEnabled: boolean
isSsoEnabled: boolean,
isApiOnly: boolean
): IDropdownOption[] => {
const disableDelete = isCurrentUser;
@ -254,7 +268,7 @@ const generateActionDropdownOptions = (
);
}
if (isSsoEnabled) {
if (isSsoEnabled || isApiOnly) {
// remove "Require password reset" from dropdownOptions
dropdownOptions = dropdownOptions.filter(
(option) => option.label !== "Require password reset"
@ -277,7 +291,8 @@ const enhanceUserData = (
actions: generateActionDropdownOptions(
user.id === currentUserId,
false,
user.sso_enabled
user.sso_enabled,
user.api_only
),
id: `user-${user.id}`,
apiId: user.id,
@ -295,7 +310,12 @@ const enhanceInviteData = (invites: IInvite[]): IUserTableData[] => {
email: invite.email,
teams: generateTeam(invite.teams, invite.global_role),
role: generateRole(invite.teams, invite.global_role),
actions: generateActionDropdownOptions(false, true, invite.sso_enabled),
actions: generateActionDropdownOptions(
false,
true,
invite.sso_enabled,
false
),
id: `invite-${invite.id}`,
apiId: invite.id,
type: "invite",

View file

@ -17,6 +17,9 @@ import {
import OrgSettingsPage from "pages/admin/OrgSettingsPage";
import AdminIntegrationsPage from "pages/admin/IntegrationsPage";
import AdminUserManagementPage from "pages/admin/UserManagementPage";
import CreateUserPage from "pages/admin/UserManagementPage/CreateUserPage";
import CreateApiUserPage from "pages/admin/UserManagementPage/CreateApiUserPage";
import EditUserPage from "pages/admin/UserManagementPage/EditUserPage";
import AdminTeamManagementPage from "pages/admin/TeamManagementPage";
import TeamDetailsWrapper from "pages/admin/TeamManagementPage/TeamDetailsWrapper";
import App from "components/App";
@ -238,6 +241,13 @@ const routes = (
{/* This redirect is used to handle old vpp setup page */}
<Redirect from="integrations/vpp/setup" to="integrations/mdm/vpp" />
<Route path="integrations/mdm/vpp" component={VppPage} />
<Route component={ExcludeInSandboxRoutes}>
<Route component={AuthGlobalAdminRoutes}>
<Route path="users/new/human" component={CreateUserPage} />
<Route path="users/new/api" component={CreateApiUserPage} />
<Route path="users/:user_id/edit" component={EditUserPage} />
</Route>
</Route>
<Redirect from="teams" to="fleets" />
<Redirect from="teams/users" to="fleets/users" />

View file

@ -45,6 +45,10 @@ export default {
ADMIN_SETTINGS: `${URL_PREFIX}/settings`,
ADMIN_USERS: `${URL_PREFIX}/settings/users`,
ADMIN_USERS_NEW_HUMAN: `${URL_PREFIX}/settings/users/new/human`,
ADMIN_USERS_NEW_API: `${URL_PREFIX}/settings/users/new/api`,
ADMIN_USERS_EDIT: (userId: number) =>
`${URL_PREFIX}/settings/users/${userId}/edit`,
// Integrations pages

View file

@ -0,0 +1,20 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { IApiEndpoint } from "interfaces/api_endpoint";
export interface IListApiEndpointsResponse {
api_endpoints: IApiEndpoint[];
}
export default {
loadAll: async (): Promise<IApiEndpoint[]> => {
const { REST_API_ENDPOINTS } = endpoints;
const response: IListApiEndpointsResponse = await sendRequest(
"GET",
REST_API_ENDPOINTS
);
return response.api_endpoints;
},
};

View file

@ -11,7 +11,8 @@ import {
IUser,
ICreateUserWithInvitationFormData,
} from "interfaces/user";
import { ITeamSummary } from "interfaces/team";
import { ITeamSummary, INewTeamUser } from "interfaces/team";
import { IApiEndpointRef } from "interfaces/api_endpoint";
import type { IRegistrationFormData } from "interfaces/registration_form_data";
import { IUserSettings } from "interfaces/config";
@ -67,10 +68,45 @@ export default {
helpers.addGravatarUrlToResource(response.user)
);
},
createUserWithoutInvitation: (formData: ICreateUserFormData) => {
createUserWithoutInvitation: (
formData: ICreateUserFormData
): Promise<{ user: IUser; token?: string }> => {
const { USERS_ADMIN } = endpoints;
return sendRequest("POST", USERS_ADMIN, formData).then((response) =>
return sendRequest("POST", USERS_ADMIN, formData).then((response) => ({
user: helpers.addGravatarUrlToResource(response.user),
token: response.token,
}));
},
createApiOnlyUser: (formData: {
name: string;
global_role?: string | null;
fleets?: INewTeamUser[];
api_endpoints?: IApiEndpointRef[] | null;
}): Promise<{ user: IUser; token?: string }> => {
const { USERS_API_ONLY } = endpoints;
return sendRequest("POST", USERS_API_ONLY, formData).then((response) => ({
user: response.user,
token: response.token,
}));
},
updateApiOnlyUser: (
userId: number,
formData: Record<string, unknown>
): Promise<IUser> => {
const { USERS_API_ONLY } = endpoints;
const path = `${USERS_API_ONLY}/${userId}`;
return sendRequest("PATCH", path, formData).then(
(response) => response.user
);
},
getUserById: (userId: number): Promise<IUser> => {
const { USERS } = endpoints;
const path = `${USERS}/${userId}`;
return sendRequest("GET", path).then((response) =>
helpers.addGravatarUrlToResource(response.user)
);
},

View file

@ -218,6 +218,7 @@ export default {
QUERIES: `/${API_VERSION}/fleet/reports`,
QUERY_REPORT: (id: number) => `/${API_VERSION}/fleet/reports/${id}/report`,
RESET_PASSWORD: `/${API_VERSION}/fleet/reset_password`,
REST_API_ENDPOINTS: `/${API_VERSION}/fleet/rest_api`,
LIVE_QUERY: `/${API_VERSION}/fleet/reports/run`,
SCHEDULE_QUERY: `/${API_VERSION}/fleet/packs/schedule`,
SCHEDULED_QUERIES: (packId: number): string => {
@ -287,6 +288,7 @@ export default {
},
USERS: `/${API_VERSION}/fleet/users`,
USERS_ADMIN: `/${API_VERSION}/fleet/users/admin`,
USERS_API_ONLY: `/${API_VERSION}/fleet/users/api_only`,
VERSION: `/${API_VERSION}/fleet/version`,
// Vulnerabilities endpoints