mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
**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>
338 lines
8.8 KiB
TypeScript
338 lines
8.8 KiB
TypeScript
import React from "react";
|
|
|
|
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
|
|
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 ActionsDropdown from "../../../../../components/ActionsDropdown";
|
|
|
|
const renderApiUserIndicator = () => {
|
|
return <PillBadge tipContent="This user only has API access.">API</PillBadge>;
|
|
};
|
|
|
|
interface IHeaderProps {
|
|
column: {
|
|
title: string;
|
|
isSortedDesc: boolean;
|
|
};
|
|
}
|
|
|
|
interface IRowProps {
|
|
row: {
|
|
original: IUserTableData;
|
|
};
|
|
}
|
|
|
|
interface ICellProps extends IRowProps {
|
|
cell: {
|
|
value: string;
|
|
};
|
|
}
|
|
|
|
interface IActionsDropdownProps extends IRowProps {
|
|
cell: {
|
|
value: IDropdownOption[];
|
|
};
|
|
}
|
|
|
|
interface IDataColumn {
|
|
title: string;
|
|
Header: ((props: IHeaderProps) => JSX.Element) | string;
|
|
accessor: string;
|
|
Cell:
|
|
| ((props: ICellProps) => JSX.Element)
|
|
| ((props: IActionsDropdownProps) => JSX.Element);
|
|
disableHidden?: boolean;
|
|
disableSortBy?: boolean;
|
|
}
|
|
|
|
export interface IUserTableData {
|
|
name: string;
|
|
status: string;
|
|
email: string;
|
|
teams: string;
|
|
role: UserRole;
|
|
actions: IDropdownOption[];
|
|
/** Prefixed ID used as a unique react-table row key (e.g. "user-3", "invite-1") */
|
|
id: string;
|
|
/** Numeric ID used for API calls */
|
|
apiId: number;
|
|
type: string;
|
|
api_only: boolean;
|
|
}
|
|
|
|
// NOTE: cellProps come from react-table
|
|
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
|
|
const generateTableHeaders = (
|
|
actionSelectHandler: (value: string, user: IUserTableData) => void,
|
|
isPremiumTier: boolean | undefined
|
|
): IDataColumn[] => {
|
|
const tableHeaders: IDataColumn[] = [
|
|
{
|
|
title: "Name",
|
|
Header: "Name",
|
|
disableSortBy: true,
|
|
accessor: "name",
|
|
Cell: (cellProps: ICellProps) => {
|
|
const apiOnlyUser =
|
|
"api_only" in cellProps.row.original
|
|
? cellProps.row.original.api_only
|
|
: false;
|
|
|
|
return (
|
|
<TooltipTruncatedTextCell
|
|
value={cellProps.cell.value}
|
|
suffix={apiOnlyUser && renderApiUserIndicator()}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: "Role",
|
|
Header: "Role",
|
|
accessor: "role",
|
|
disableSortBy: true,
|
|
Cell: (cellProps: ICellProps) => {
|
|
if (cellProps.cell.value === "GitOps") {
|
|
return (
|
|
<TooltipWrapper
|
|
tipContent={
|
|
<>
|
|
The GitOps role is only available for API-only
|
|
<br />
|
|
users. This user has no access to the UI.
|
|
</>
|
|
}
|
|
>
|
|
GitOps
|
|
</TooltipWrapper>
|
|
);
|
|
}
|
|
if (cellProps.cell.value === "Observer+") {
|
|
return (
|
|
<TooltipWrapper
|
|
tipContent={
|
|
<>
|
|
Users with the Observer+ role have access to all of
|
|
<br />
|
|
the same functions as an Observer, with the added
|
|
<br />
|
|
ability to run any live report against all hosts.
|
|
</>
|
|
}
|
|
>
|
|
{cellProps.cell.value}
|
|
</TooltipWrapper>
|
|
);
|
|
}
|
|
const greyAndItalic = greyCell(cellProps.cell.value);
|
|
return (
|
|
<TextCell
|
|
value={cellProps.cell.value}
|
|
grey={greyAndItalic}
|
|
italic={greyAndItalic}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: "Status",
|
|
Header: (cellProps) => (
|
|
<HeaderCell
|
|
value={cellProps.column.title}
|
|
isSortedDesc={cellProps.column.isSortedDesc}
|
|
/>
|
|
),
|
|
accessor: "status",
|
|
Cell: (cellProps: ICellProps) => (
|
|
<StatusIndicator value={cellProps.cell.value} />
|
|
),
|
|
},
|
|
{
|
|
title: "Email",
|
|
Header: "Email",
|
|
disableSortBy: true,
|
|
accessor: "email",
|
|
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",
|
|
Header: "",
|
|
disableSortBy: true,
|
|
accessor: "actions",
|
|
Cell: (cellProps: IActionsDropdownProps) => (
|
|
<ActionsDropdown
|
|
options={cellProps.cell.value}
|
|
onChange={(value: string) =>
|
|
actionSelectHandler(value, cellProps.row.original)
|
|
}
|
|
placeholder="Actions"
|
|
menuAlign="right"
|
|
variant="small-button"
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
// Add Teams column for premium tier
|
|
if (isPremiumTier) {
|
|
tableHeaders.splice(2, 0, {
|
|
title: "Fleets",
|
|
Header: "Fleets",
|
|
accessor: "teams",
|
|
disableSortBy: true,
|
|
Cell: (cellProps: ICellProps) => (
|
|
<TextCell value={cellProps.cell.value} />
|
|
),
|
|
});
|
|
}
|
|
|
|
return tableHeaders;
|
|
};
|
|
|
|
const generateStatus = (type: string, data: IUser | IInvite): string => {
|
|
const { teams, global_role } = data;
|
|
if (global_role === null && teams.length === 0) {
|
|
return "No access";
|
|
}
|
|
|
|
return type === "invite" ? "Invite pending" : "Active";
|
|
};
|
|
|
|
const generateActionDropdownOptions = (
|
|
isCurrentUser: boolean,
|
|
isInvitePending: boolean,
|
|
isSsoEnabled: boolean,
|
|
isApiOnly: boolean
|
|
): IDropdownOption[] => {
|
|
const disableDelete = isCurrentUser;
|
|
|
|
let dropdownOptions = [
|
|
{
|
|
label: "Edit",
|
|
disabled: false,
|
|
value: isCurrentUser ? "editMyAccount" : "edit",
|
|
},
|
|
{
|
|
label: "Require password reset",
|
|
disabled: isInvitePending,
|
|
value: "passwordReset",
|
|
},
|
|
{
|
|
label: "Reset sessions",
|
|
disabled: isInvitePending,
|
|
value: "resetSessions",
|
|
},
|
|
{
|
|
label: "Delete",
|
|
disabled: disableDelete,
|
|
value: "delete",
|
|
tooltipContent: disableDelete ? (
|
|
<>
|
|
There must be at least one Admin
|
|
<br />
|
|
user on the account. To delete this
|
|
<br />
|
|
user, add or set existing user with
|
|
<br />
|
|
role of "Admin".
|
|
</>
|
|
) : undefined,
|
|
},
|
|
];
|
|
|
|
if (isCurrentUser) {
|
|
// remove "Reset sessions" from dropdownOptions
|
|
dropdownOptions = dropdownOptions.filter(
|
|
(option) => option.label !== "Reset sessions"
|
|
);
|
|
}
|
|
|
|
if (isSsoEnabled || isApiOnly) {
|
|
// remove "Require password reset" from dropdownOptions
|
|
dropdownOptions = dropdownOptions.filter(
|
|
(option) => option.label !== "Require password reset"
|
|
);
|
|
}
|
|
return dropdownOptions;
|
|
};
|
|
|
|
const enhanceUserData = (
|
|
users: IUser[],
|
|
currentUserId: number
|
|
): IUserTableData[] => {
|
|
return users.map((user) => {
|
|
return {
|
|
name: user.name || DEFAULT_EMPTY_CELL_VALUE,
|
|
status: generateStatus("user", user),
|
|
email: user.email,
|
|
teams: generateTeam(user.teams, user.global_role),
|
|
role: generateRole(user.teams, user.global_role),
|
|
actions: generateActionDropdownOptions(
|
|
user.id === currentUserId,
|
|
false,
|
|
user.sso_enabled,
|
|
user.api_only
|
|
),
|
|
id: `user-${user.id}`,
|
|
apiId: user.id,
|
|
type: "user",
|
|
api_only: user.api_only,
|
|
};
|
|
});
|
|
};
|
|
|
|
const enhanceInviteData = (invites: IInvite[]): IUserTableData[] => {
|
|
return invites.map((invite) => {
|
|
return {
|
|
name: invite.name || DEFAULT_EMPTY_CELL_VALUE,
|
|
status: generateStatus("invite", invite),
|
|
email: invite.email,
|
|
teams: generateTeam(invite.teams, invite.global_role),
|
|
role: generateRole(invite.teams, invite.global_role),
|
|
actions: generateActionDropdownOptions(
|
|
false,
|
|
true,
|
|
invite.sso_enabled,
|
|
false
|
|
),
|
|
id: `invite-${invite.id}`,
|
|
apiId: invite.id,
|
|
type: "invite",
|
|
api_only: false, // api only users are created through fleetctl and not invites
|
|
};
|
|
});
|
|
};
|
|
|
|
const combineDataSets = (
|
|
users: IUser[],
|
|
invites: IInvite[],
|
|
currentUserId: number
|
|
): IUserTableData[] => {
|
|
return [
|
|
...enhanceUserData(users, currentUserId),
|
|
...enhanceInviteData(invites),
|
|
];
|
|
};
|
|
|
|
export { generateTableHeaders, combineDataSets };
|