mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
[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:
parent
2a8803884b
commit
578f35292c
28 changed files with 1613 additions and 328 deletions
1
changes/42879-api-only-user-management-ui
Normal file
1
changes/42879-api-only-user-management-ui
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
12
frontend/interfaces/api_endpoint.ts
Normal file
12
frontend/interfaces/api_endpoint.ts
Normal 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}`;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./CreateApiUserPage";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./CreateUserPage";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./EditUserPage";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ApiAccessSection";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ApiEndpointSelectorTable";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ApiKeyDisplay";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./ApiUserForm";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
20
frontend/services/entities/api_endpoints.ts
Normal file
20
frontend/services/entities/api_endpoints.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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)
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue