feat: enforce user roles

enforces user admin role requirement for
- creating / deleting / setting role for organization users
- creating / deleting / setting role for project users
- updating project name
- deleting project

hides action elements based on role for
- admin console
- team settings if team is only visible through project membership
- add project tile if not team admin
- project name text editor if not team / project admin
- add redirect from team page if settings only visible through project
  membership
- add redirect from admin console if not org admin

role enforcement is handled on the api side through a custom GraphQL
directive `hasRole`. on the client side, role information is fetched in
the TopNavbar's `me` query and stored in the `UserContext`.

there is a custom hook, `useCurrentUser`, that provides a user object
with two functions, `isVisibile` & `isAdmin` which is used to check
roles in order to render/hide relevant UI elements.
This commit is contained in:
Jordan Knott
2020-07-31 20:01:14 -05:00
committed by Jordan Knott
parent 5dbdc20b36
commit e64f6f8569
63 changed files with 3017 additions and 1905 deletions

View File

@ -25,6 +25,7 @@ export const Default = () => {
<ThemeProvider theme={theme}>
<Admin
onInviteUser={action('invite user')}
canInviteUser
initialTab={1}
onUpdateUserPassword={action('update user password')}
onDeleteUser={action('delete user')}

View File

@ -55,7 +55,7 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
position: relative;
text-decoration: none;
${(props) =>
${props =>
props.disabled
? css`
user-select: none;
@ -75,7 +75,7 @@ export const Content = styled.div`
export const CurrentPermission = styled.span`
margin-left: 4px;
color: rgba(${(props) => props.theme.colors.text.secondary}, 0.4);
color: rgba(${props => props.theme.colors.text.secondary}, 0.4);
`;
export const Separator = styled.div`
@ -86,13 +86,13 @@ export const Separator = styled.div`
export const WarningText = styled.span`
display: flex;
color: rgba(${(props) => props.theme.colors.text.primary}, 0.4);
color: rgba(${props => props.theme.colors.text.primary}, 0.4);
padding: 6px;
`;
export const DeleteDescription = styled.div`
font-size: 14px;
color: rgba(${(props) => props.theme.colors.text.primary});
color: rgba(${props => props.theme.colors.text.primary});
`;
export const RemoveMemberButton = styled(Button)`
@ -159,8 +159,8 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<MiniProfileActions>
<MiniProfileActionWrapper>
{permissions
.filter((p) => (user.role && user.role.code === 'owner') || p.code !== 'owner')
.map((perm) => (
.filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner')
.map(perm => (
<MiniProfileActionItem
disabled={user.role && perm.code !== user.role.code && !canChangeRole}
key={perm.code}
@ -211,9 +211,9 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
Choose a new user to take over ownership of this user's teams & projects.
</DeleteDescription>
<UserSelect
onChange={(v) => setDeleteUser(v)}
onChange={v => setDeleteUser(v)}
value={deleteUser}
options={users.map((u) => ({ label: u.fullName, value: u.id }))}
options={users.map(u => ({ label: u.fullName, value: u.id }))}
/>
</>
)}
@ -239,11 +239,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
</DeleteDescription>
<DeleteDescription>{`The user is the owner of ${user.owned.projects.length} projects & ${user.owned.teams.length} teams.`}</DeleteDescription>
<UserSelect
onChange={() => {}}
value={null}
options={users.map((u) => ({ label: u.fullName, value: u.id }))}
/>
<UserSelect onChange={() => {}} value={null} options={users.map(u => ({ label: u.fullName, value: u.id }))} />
<UserPassConfirmButton
onClick={() => {
// onDeleteUser();
@ -334,14 +330,14 @@ const MemberItemOption = styled(Button)`
`;
const MemberList = styled.div`
border-top: 1px solid rgba(${(props) => props.theme.colors.border});
border-top: 1px solid rgba(${props => props.theme.colors.border});
`;
const MemberListItem = styled.div`
display: flex;
flex-flow: row wrap;
justify-content: space-between;
border-bottom: 1px solid rgba(${(props) => props.theme.colors.border});
border-bottom: 1px solid rgba(${props => props.theme.colors.border});
min-height: 40px;
padding: 12px 0 12px 40px;
position: relative;
@ -365,11 +361,11 @@ const MemberProfile = styled(TaskAssignee)`
`;
const MemberItemName = styled.p`
color: rgba(${(props) => props.theme.colors.text.secondary});
color: rgba(${props => props.theme.colors.text.secondary});
`;
const MemberItemUsername = styled.p`
color: rgba(${(props) => props.theme.colors.text.primary});
color: rgba(${props => props.theme.colors.text.primary});
`;
const MemberListHeader = styled.div`
@ -378,12 +374,12 @@ const MemberListHeader = styled.div`
`;
const ListTitle = styled.h3`
font-size: 18px;
color: rgba(${(props) => props.theme.colors.text.secondary});
color: rgba(${props => props.theme.colors.text.secondary});
margin-bottom: 12px;
`;
const ListDesc = styled.span`
font-size: 16px;
color: rgba(${(props) => props.theme.colors.text.primary});
color: rgba(${props => props.theme.colors.text.primary});
`;
const FilterSearch = styled(Input)`
margin: 0;
@ -484,7 +480,7 @@ const ActionButtons = (params: any) => {
<ActionButton onClick={() => {}}>
<EditUserIcon width={16} height={16} />
</ActionButton>
<ActionButton onClick={($target) => params.onDeleteUser($target, params.value)}>
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
<DeleteUserIcon width={16} height={16} />
</ActionButton>
</>
@ -541,7 +537,7 @@ const TabNavItemButton = styled.button<{ active: boolean }>`
width: 100%;
position: relative;
color: ${(props) => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')};
color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')};
&:hover {
color: rgba(115, 103, 240);
}
@ -562,7 +558,7 @@ const TabNavLine = styled.span<{ top: number }>`
width: 2px;
height: 48px;
transform: scaleX(1);
top: ${(props) => props.top}px;
top: ${props => props.top}px;
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240));
box-shadow: 0 0 8px 0 rgba(115, 103, 240);
@ -624,6 +620,7 @@ type AdminProps = {
onDeleteUser: (userID: string, newOwnerID: string | null) => void;
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
users: Array<User>;
canInviteUser: boolean;
onUpdateUserPassword: (user: TaskUser, password: string) => void;
};
@ -631,6 +628,7 @@ const Admin: React.FC<AdminProps> = ({
initialTab,
onAddUser,
onUpdateUserPassword,
canInviteUser,
onDeleteUser,
onInviteUser,
users,
@ -675,18 +673,20 @@ const Admin: React.FC<AdminProps> = ({
</ListDesc>
<ListActions>
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
<InviteMemberButton
onClick={($target) => {
onAddUser($target);
}}
>
<InviteIcon width={16} height={16} />
New Member
</InviteMemberButton>
{canInviteUser && (
<InviteMemberButton
onClick={$target => {
onAddUser($target);
}}
>
<InviteIcon width={16} height={16} />
New Member
</InviteMemberButton>
)}
</ListActions>
</MemberListHeader>
<MemberList>
{users.map((member) => {
{users.map(member => {
const projectTotal = member.owned.projects.length + member.member.projects.length;
return (
<MemberListItem>
@ -699,7 +699,7 @@ const Admin: React.FC<AdminProps> = ({
<MemberItemOption variant="flat">{`On ${projectTotal} projects`}</MemberItemOption>
<MemberItemOption
variant="outline"
onClick={($target) => {
onClick={$target => {
showPopup(
$target,
<TeamRoleManagerPopup
@ -710,7 +710,7 @@ const Admin: React.FC<AdminProps> = ({
onUpdateUserPassword(user, password);
}}
canChangeRole={(member.role && member.role.code !== 'owner') ?? false}
onChangeRole={(roleCode) => {
onChangeRole={roleCode => {
updateUserRole({ variables: { userID: member.id, roleCode } });
}}
onDeleteUser={onDeleteUser}

View File

@ -37,17 +37,22 @@ const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top, onLogout, onClos
type ProfileMenuProps = {
onProfile: () => void;
onLogout: () => void;
showAdminConsole: boolean;
onAdminConsole: () => void;
};
const ProfileMenu: React.FC<ProfileMenuProps> = ({ onAdminConsole, onProfile, onLogout }) => {
const ProfileMenu: React.FC<ProfileMenuProps> = ({ showAdminConsole, onAdminConsole, onProfile, onLogout }) => {
return (
<>
<ActionItem onClick={onAdminConsole}>
<Cog size={16} color="#c2c6dc" />
<ActionTitle>Admin Console</ActionTitle>
</ActionItem>
<Separator />
{showAdminConsole && (
<>
<ActionItem onClick={onAdminConsole}>
<Cog size={16} color="#c2c6dc" />
<ActionTitle>Admin Console</ActionTitle>
</ActionItem>
<Separator />
</>
)}
<ActionItem onClick={onProfile}>
<User size={16} color="#c2c6dc" />
<ActionTitle>Profile</ActionTitle>

View File

@ -50,7 +50,6 @@ type MiniProfileProps = {
onRemoveFromTask?: () => void;
onChangeRole?: (roleCode: RoleCode) => void;
onRemoveFromBoard?: () => void;
onChangeProjectOwner?: (userID: string) => void;
warning?: string | null;
canChangeRole?: boolean;
};
@ -58,7 +57,6 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
user,
bio,
canChangeRole,
onChangeProjectOwner,
onRemoveFromTask,
onChangeRole,
onRemoveFromBoard,
@ -91,15 +89,6 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
Remove from card
</MiniProfileActionItem>
)}
{onChangeProjectOwner && (
<MiniProfileActionItem
onClick={() => {
setTab(3);
}}
>
Set as project owner
</MiniProfileActionItem>
)}
{onChangeRole && user.role && (
<MiniProfileActionItem
onClick={() => {
@ -193,24 +182,6 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
</RemoveMemberButton>
</Content>
</Popup>
<Popup title="Set as Project Owner?" onClose={() => hidePopup()} tab={3}>
<Content>
<DeleteDescription>
This will change the project owner from you to this user. They will be able to view and edit cards, remove
members, and change all settings for the project. They will also be able to delete the project.
</DeleteDescription>
<RemoveMemberButton
color="warning"
onClick={() => {
if (onChangeProjectOwner) {
onChangeProjectOwner(user.id);
}
}}
>
Set as Project Owner
</RemoveMemberButton>
</Content>
</Popup>
</>
);
};

View File

@ -38,6 +38,7 @@ const HomeDashboard = styled(Home)``;
type ProjectHeadingProps = {
onFavorite?: () => void;
name: string;
canEditProjectName: boolean;
onSaveProjectName?: (projectName: string) => void;
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
};
@ -46,6 +47,7 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
onFavorite,
name: initialProjectName,
onSaveProjectName,
canEditProjectName,
onOpenSettings,
}) => {
const [isEditProjectName, setEditProjectName] = useState(false);
@ -94,7 +96,9 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
) : (
<ProjectName
onClick={() => {
setEditProjectName(true);
if (canEditProjectName) {
setEditProjectName(true);
}
}}
>
{projectName}
@ -142,19 +146,25 @@ type NavBarProps = {
onProfileClick: ($target: React.RefObject<HTMLElement>) => void;
onSaveName?: (name: string) => void;
onNotificationClick: () => void;
canEditProjectName?: boolean;
canInviteUser?: boolean;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onDashboardClick: () => void;
user: TaskUser | null;
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
projectMembers?: Array<TaskUser> | null;
onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
};
const NavBar: React.FC<NavBarProps> = ({
menuType,
canInviteUser = false,
onInviteUser,
onChangeProjectOwner,
currentTab,
onMemberProfile,
canEditProjectName = false,
onOpenProjectFinder,
onFavorite,
onSetTab,
@ -175,47 +185,6 @@ const NavBar: React.FC<NavBarProps> = ({
}
};
const { showPopup } = usePopup();
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
if (member) {
console.log(member);
showPopup(
$targetRef,
<MiniProfile
warning={member.role && member.role.code === 'owner' ? warning : null}
onChangeProjectOwner={
member.role && member.role.code !== 'owner'
? (userID: string) => {
if (user && onChangeProjectOwner) {
onChangeProjectOwner(userID);
}
}
: undefined
}
canChangeRole={member.role && member.role.code !== 'owner'}
onChangeRole={roleCode => {
if (onChangeRole) {
onChangeRole(member.id, roleCode);
}
}}
onRemoveFromBoard={
member.role && member.role.code === 'owner'
? undefined
: () => {
if (onRemoveFromBoard) {
onRemoveFromBoard(member.id);
}
}
}
user={member}
bio=""
/>,
);
}
};
return (
<NavbarWrapper>
<NavbarHeader>
@ -226,6 +195,7 @@ const NavBar: React.FC<NavBarProps> = ({
onFavorite={onFavorite}
onOpenSettings={onOpenSettings}
name={name}
canEditProjectName={canEditProjectName}
onSaveProjectName={onSaveName}
/>
)}
@ -255,7 +225,7 @@ const NavBar: React.FC<NavBarProps> = ({
<TaskcafeTitle>Taskcafé</TaskcafeTitle>
</LogoContainer>
<GlobalActions>
{projectMembers && (
{projectMembers && onMemberProfile && (
<>
<ProjectMembers>
{projectMembers.map((member, idx) => (
@ -268,16 +238,18 @@ const NavBar: React.FC<NavBarProps> = ({
onMemberProfile={onMemberProfile}
/>
))}
<InviteButton
onClick={$target => {
if (onInviteUser) {
onInviteUser($target);
}
}}
variant="outline"
>
Invite
</InviteButton>
{canInviteUser && (
<InviteButton
onClick={$target => {
if (onInviteUser) {
onInviteUser($target);
}
}}
variant="outline"
>
Invite
</InviteButton>
)}
</ProjectMembers>
<NavSeparator />
</>