Skip to content

Commit

Permalink
🪟 🎉 New user invitation UI -- Allow assigning roles at the point of i…
Browse files Browse the repository at this point in the history
…nvitation (#11632)
  • Loading branch information
teallarson committed Mar 19, 2024
1 parent 6941b97 commit 27827ce
Show file tree
Hide file tree
Showing 16 changed files with 297 additions and 139 deletions.
4 changes: 4 additions & 0 deletions airbyte-webapp/src/core/api/hooks/userInvitations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ export const useCreateUserInvitation = () => {
text: formatMessage({ id: "userInvitations.create.success" }),
id: "userInvitations.create.success",
});
const keyScope = invitationCreate.scopeType === "workspace" ? SCOPE_WORKSPACE : SCOPE_ORGANIZATION;

// this endpoint will direct add users who are already within the org, so we want to invalidate both the invitations and the members lists
queryClient.invalidateQueries(workspaceKeys.allListAccessUsers);
queryClient.invalidateQueries([keyScope, "userInvitations"]);
return response;
})
.catch(() => {
Expand Down
3 changes: 2 additions & 1 deletion airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1702,5 +1702,6 @@
"userInvitations.create.modal.organizationAdminTooltip": "This user already has full access to this workspace.",
"userInvitations.pendingInvitation": "Pending...",
"userInvitations.pendingInvitation.tooltipMain": "This member has not yet accepted their invitation.",
"userInvitations.pendingInvitation.tooltipAdditionalInfo": "They do not yet have access to this workspace."
"userInvitations.pendingInvitation.tooltipAdditionalInfo": "They do not yet have access to this workspace.",
"userInvitations.create.modal.asRole": "As {role}"
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const AddUserModal: React.FC<{ closeModal: () => void }> = ({ closeModal
When they begin typing, we filter a list that is a superset of workspaceAccessUsers + organization users. We want to prefer the workspaceAccessUsers
object for a given user (if present) because it contains all relevant permissions for the user.
Then, we enrich that from the list of organization_member who don't have a permission to this workspace.
Then, we enrich that from the list of organization_members who don't have a permission to this workspace.
*/
const userMap = new Map();

Expand All @@ -96,9 +96,10 @@ export const AddUserModal: React.FC<{ closeModal: () => void }> = ({ closeModal
});

users.forEach((user) => {
// the first check here is important only for the "empty search value" case, where we want to show all users who don't have a workspace permission
// for other cases, it is at worst slightly redundant
if (user.permissionType === "organization_member" && !userMap.has(user.userId)) {
if (
user.permissionType === "organization_member" && // they are an organization_member
!usersWithAccess.some((u) => u.userId === user.userId) // they don't have a workspace permission (they may not be listed)
) {
userMap.set(user.userId, {
userId: user.userId,
userName: user.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
@use "scss/variables";

.addUserModalBody {
height: 400px;
padding: variables.$spacing-md;

&__list,
&__listItem {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ModalBody } from "components/ui/Modal";
import { Text } from "components/ui/Text";

import { useCurrentOrganizationInfo } from "core/api";
import { WorkspaceUserAccessInfoRead } from "core/api/types/AirbyteClient";
import { PermissionType, WorkspaceUserAccessInfoRead } from "core/api/types/AirbyteClient";

import { AddUserFormValues } from "./AddUserModal";
import styles from "./AddUserModalBody.module.scss";
Expand All @@ -34,22 +34,21 @@ export const AddUserModalBody: React.FC<AddUserModalBodyProps> = ({

// handle when the selected option is no longer visible
useEffect(() => {
// user had selected to invite a new user, then changed the search value so that option is no longer valid, clear form value
if (selectedRow === "inviteNewUser" && !showInviteNewUser) {
setSelectedRow(null);
setValue("email", "", { shouldValidate: true });
}
const resetPredicates = [
// user had selected to invite a new user, then changed the search value so that option is no longer valid
selectedRow === "inviteNewUser" && !showInviteNewUser,

// user had selected to invite a new user, then changed the search value to another valid option, clear form value and deselect
if (selectedRow === "inviteNewUser" && deferredSearchValue !== getValues("email")) {
setSelectedRow(null);
setValue("email", "", { shouldValidate: true });
}
// user had selected to invite a new user, then changed the search value to another valid email
selectedRow === "inviteNewUser" && deferredSearchValue !== getValues("email"),

// user had selected a user and that user is no longer visible
selectedRow && selectedRow !== "inviteNewUser" && !usersToList.find((user) => user.userId === selectedRow),
];

// user had selected a user and that user is no longer visible, clear it
if (selectedRow && selectedRow !== "inviteNewUser" && !usersToList.find((user) => user.userId === selectedRow)) {
if (resetPredicates.some(Boolean)) {
setSelectedRow(null);
setValue("email", "", { shouldValidate: true });
setValue("permission", PermissionType.workspace_admin, { shouldValidate: true });
}
}, [usersToList, showInviteNewUser, selectedRow, setSelectedRow, setValue, deferredSearchValue, getValues]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,46 @@
border-bottom: variables.$border-thin solid colors.$grey-50;

&__label {
padding: variables.$spacing-md 0;
display: inline-block;
width: 100%;
cursor: pointer;
padding-left: variables.$spacing-md;
padding-right: variables.$spacing-md;
height: 60px;

&:hover {
background-color: colors.$grey-50;
}
}

&__labelContent {
height: 100%;
}

&__dot {
flex: 0 0 auto;
line-height: 0;
}

&__hiddenInput {
@include mixins.visually-hidden;

&:checked {
+ .radioButtonTiles__toggle {
border-color: colors.$blue;
}
}

&:focus-visible {
+ .radioButtonTiles__toggle {
outline: 2px solid colors.$blue-900;
+ .inviteUserRow__label {
outline: variables.$border-thin solid colors.$blue-900;
}
}
}

&__listBoxButton {
border: none;
border-radius: variables.$border-radius-sm;
background-color: transparent;
cursor: pointer;
}

&__listBoxButton:hover {
background-color: colors.$grey-100;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,27 @@ import { useFormContext } from "react-hook-form";
import { FormattedMessage, useIntl } from "react-intl";

import { SelectedIndicatorDot } from "components/connection/CreateConnection/SelectedIndicatorDot";
import { Badge } from "components/ui/Badge";
import { Box } from "components/ui/Box";
import { FlexContainer } from "components/ui/Flex";
import { FlexContainer, FlexItem } from "components/ui/Flex";
import { Icon } from "components/ui/Icon";
import { ListBox } from "components/ui/ListBox";
import { Text } from "components/ui/Text";
import { Tooltip } from "components/ui/Tooltip";

import { PermissionType, WorkspaceUserAccessInfoRead } from "core/api/types/AirbyteClient";
import { useCurrentUser } from "core/services/auth";
import { getWorkspaceAccessLevel } from "pages/SettingsPage/pages/AccessManagementPage/components/useGetAccessManagementData";
import { FeatureItem, useFeature } from "core/services/features";
import { partitionPermissionType } from "core/utils/rbac/rbacPermissionsQuery";
import {
getWorkspaceAccessLevel,
permissionsByResourceType,
} from "pages/SettingsPage/pages/AccessManagementPage/components/useGetAccessManagementData";
import { UserRoleText } from "pages/SettingsPage/pages/AccessManagementPage/components/UserRoleText";
import { disallowedRoles } from "pages/SettingsPage/pages/AccessManagementPage/next/ChangeRoleMenuItem";
import { ChangeRoleMenuItemContent } from "pages/SettingsPage/pages/AccessManagementPage/next/ChangeRoleMenuItemContent";

import { AddUserFormValues } from "./AddUserModal";
import { ExistingUserIndicator } from "./ExistingUserIndicator";
import styles from "./InviteUserRow.module.scss";
import { ViewOnlyUserRow } from "./ViewOnlyUserRow";

interface InviteUserRowProps {
id: string;
Expand All @@ -27,62 +35,43 @@ interface InviteUserRowProps {
}

export const InviteUserRow: React.FC<InviteUserRowProps> = ({ id, name, email, selectedRow, setSelectedRow, user }) => {
const [permissionType] = useState<PermissionType>(PermissionType.workspace_admin);
const allowAllRBACRoles = useFeature(FeatureItem.AllowAllRBACRoles);

const [selectedPermissionType, setPermissionType] = useState<PermissionType>(PermissionType.workspace_admin);
const { setValue } = useFormContext<AddUserFormValues>();
const { formatMessage } = useIntl();
const { userId: currentUserId } = useCurrentUser();
const isCurrentUser = user?.userId === currentUserId;
const isOrgAdmin = user?.organizationPermission?.permissionType === PermissionType.organization_admin;

const onSelectRow = () => {
setSelectedRow(id);
setValue("permission", permissionType, { shouldValidate: true });
setValue("permission", selectedPermissionType, { shouldValidate: true });
setValue("email", email, { shouldValidate: true });
};

const onSelectPermission = (selectedValue: PermissionType) => {
setPermissionType(selectedValue);
setValue("permission", selectedValue, { shouldValidate: true });
};

const shouldDisableRow = useMemo(() => {
return id === "inviteNewUser"
? false
: user?.organizationPermission?.permissionType === PermissionType.organization_admin ||
!!user?.workspacePermission?.permissionType ||
user?.userId === currentUserId;
}, [currentUserId, id, user]);
return id === "inviteNewUser" ? false : isOrgAdmin || !!user?.workspacePermission?.permissionType || isCurrentUser;
}, [id, isOrgAdmin, isCurrentUser, user]);

const highestPermissionType = user ? getWorkspaceAccessLevel(user) : undefined;

if (shouldDisableRow && highestPermissionType) {
const selectedPermissionTypeString = partitionPermissionType(selectedPermissionType)[1];

if (shouldDisableRow) {
return (
<Box py="md" className={styles.inviteUserRow}>
<FlexContainer justifyContent="space-between" alignItems="center">
<FlexContainer direction="column" gap="none" justifyContent="center">
<Text>
{name}
{user?.userId === currentUserId && (
<Box as="span" px="sm">
<Badge variant="grey">
<FormattedMessage id="settings.accessManagement.youHint" />
</Badge>
</Box>
)}
</Text>
<Text color="grey400" italicized>
{email}
</Text>
</FlexContainer>
{user?.organizationPermission?.permissionType === PermissionType.organization_admin ? (
<Tooltip
control={
<Badge variant="grey">
<FormattedMessage id="role.organizationAdmin" />
</Badge>
}
placement="top-start"
>
<FormattedMessage id="userInvitations.create.modal.organizationAdminTooltip" />
</Tooltip>
) : (
<ExistingUserIndicator highestPermissionType={highestPermissionType} />
)}
</FlexContainer>
</Box>
<ViewOnlyUserRow
name={name}
email={email}
isCurrentUser={isCurrentUser}
isOrgAdmin={isOrgAdmin}
highestPermissionType={highestPermissionType}
/>
);
}

Expand All @@ -101,21 +90,61 @@ export const InviteUserRow: React.FC<InviteUserRowProps> = ({ id, name, email, s
{/* the linter cannot seem to keep track of the input + label here */}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className={styles.inviteUserRow__label} htmlFor={id}>
<Box py="md">
<FlexContainer justifyContent="space-between" alignItems="center">
<FlexContainer direction="column" gap="none" justifyContent="center">
<Text>
{id === "inviteNewUser" ? formatMessage({ id: "userInvitations.create.modal.addNew" }) : name}
</Text>
<Text color="grey400" italicized>
{email}
</Text>
</FlexContainer>
<FlexContainer
justifyContent="space-between"
alignItems="center"
className={styles.inviteUserRow__labelContent}
>
<FlexContainer direction="column" gap="none" justifyContent="center">
<Text>{id === "inviteNewUser" ? formatMessage({ id: "userInvitations.create.modal.addNew" }) : name}</Text>
<Text color="grey400" italicized>
{email}
</Text>
</FlexContainer>
<FlexContainer alignItems="center">
{allowAllRBACRoles && selectedRow === id && (
<ListBox<PermissionType>
buttonClassName={styles.inviteUserRow__listBoxButton}
selectedValue={selectedPermissionType}
controlButton={() => (
<Box py="sm" px="xs">
<FlexContainer direction="row" alignItems="center" gap="xs">
<Text size="md" color="grey" as="span">
<FormattedMessage
id="userInvitations.create.modal.asRole"
values={{ role: <UserRoleText highestPermissionType={selectedPermissionTypeString} /> }}
/>
</Text>
<FlexItem>
<Icon type="chevronDown" color="disabled" size="sm" />
</FlexItem>
</FlexContainer>
</Box>
)}
options={permissionsByResourceType.workspace.map((optionPermissionType) => {
return {
label: (
<ChangeRoleMenuItemContent
permissionType={optionPermissionType}
roleIsInvalid={
!!user
? disallowedRoles(user, "workspace", isCurrentUser).includes(optionPermissionType)
: false
}
roleIsActive={optionPermissionType === selectedPermissionType}
/>
),
value: optionPermissionType,
};
})}
onSelect={onSelectPermission}
/>
)}
<div className={styles.inviteUserRow__dot}>
<SelectedIndicatorDot selected={selectedRow === id} />
</div>
</FlexContainer>
</Box>
</FlexContainer>
</label>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@use "scss/variables";
@use "scss/colors";

.existingUserRow {
padding-left: variables.$spacing-md;
padding-right: variables.$spacing-md;
height: 60px;
border-bottom: variables.$border-thin solid colors.$grey-50;

&__content {
height: 100%;
}
}
Loading

0 comments on commit 27827ce

Please sign in to comment.