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:
committed by
Jordan Knott
parent
5dbdc20b36
commit
e64f6f8569
@ -1,31 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { Home, Stack } from 'shared/icons';
|
||||
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from 'shared/components/Navbar';
|
||||
import { Link } from 'react-router-dom';
|
||||
import UserIDContext from './context';
|
||||
|
||||
const GlobalNavbar = () => {
|
||||
const { userID } = useContext(UserIDContext);
|
||||
if (!userID) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Navbar>
|
||||
<PrimaryLogo />
|
||||
<ButtonContainer>
|
||||
<Link to="/">
|
||||
<ActionButton name="Home">
|
||||
<Home width={28} height={28} />
|
||||
</ActionButton>
|
||||
</Link>
|
||||
<Link to="/projects">
|
||||
<ActionButton name="Projects">
|
||||
<Stack size={28} color="#c2c6dc" />
|
||||
</ActionButton>
|
||||
</Link>
|
||||
</ButtonContainer>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalNavbar;
|
@ -4,7 +4,7 @@ import styled from 'styled-components/macro';
|
||||
import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu';
|
||||
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
|
||||
import { useHistory } from 'react-router';
|
||||
import UserIDContext from 'App/context';
|
||||
import { UserContext, PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
|
||||
import {
|
||||
RoleCode,
|
||||
useMeQuery,
|
||||
@ -16,6 +16,8 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { History } from 'history';
|
||||
import produce from 'immer';
|
||||
import { Link } from 'react-router-dom';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
import cache from 'App/cache';
|
||||
|
||||
const TeamContainer = styled.div`
|
||||
display: flex;
|
||||
@ -221,6 +223,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, proje
|
||||
type GlobalTopNavbarProps = {
|
||||
nameOnly?: boolean;
|
||||
projectID: string | null;
|
||||
teamID?: string | null;
|
||||
onChangeProjectOwner?: (userID: string) => void;
|
||||
name: string | null;
|
||||
currentTab?: number;
|
||||
@ -239,6 +242,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
onSetTab,
|
||||
menuType,
|
||||
projectID,
|
||||
teamID,
|
||||
onChangeProjectOwner,
|
||||
onChangeRole,
|
||||
name,
|
||||
@ -250,10 +254,27 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
nameOnly,
|
||||
}) => {
|
||||
console.log(popupContent);
|
||||
const { loading, data } = useMeQuery();
|
||||
const { user, setUserRoles, setUser } = useCurrentUser();
|
||||
const { loading, data } = useMeQuery({
|
||||
onCompleted: data => {
|
||||
console.log('me query has completed!');
|
||||
if (user && user.roles) {
|
||||
setUserRoles({
|
||||
org: user.roles.org,
|
||||
teams: data.me.teamRoles.reduce((map, obj) => {
|
||||
map.set(obj.teamID, obj.roleCode);
|
||||
return map;
|
||||
}, new Map<string, string>()),
|
||||
projects: data.me.projectRoles.reduce((map, obj) => {
|
||||
map.set(obj.projectID, obj.roleCode);
|
||||
return map;
|
||||
}, new Map<string, string>()),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const { showPopup, hidePopup, setTab } = usePopup();
|
||||
const history = useHistory();
|
||||
const { userID, setUserID } = useContext(UserIDContext);
|
||||
const onLogout = () => {
|
||||
fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
@ -261,8 +282,9 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
}).then(async x => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
cache.reset();
|
||||
history.replace('/login');
|
||||
setUserID(null);
|
||||
setUser(null);
|
||||
hidePopup();
|
||||
}
|
||||
});
|
||||
@ -273,6 +295,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
<Popup title={null} tab={0}>
|
||||
<ProfileMenu
|
||||
onLogout={onLogout}
|
||||
showAdminConsole={user ? user.roles.org === 'admin' : false}
|
||||
onAdminConsole={() => {
|
||||
history.push('/admin');
|
||||
hidePopup();
|
||||
@ -295,9 +318,41 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!userID) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
|
||||
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
|
||||
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
|
||||
const warning =
|
||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<MiniProfile
|
||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||
canChangeRole={userIsTeamOrProjectAdmin}
|
||||
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 (
|
||||
<>
|
||||
<TopNavbar
|
||||
@ -312,7 +367,10 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
|
||||
);
|
||||
}}
|
||||
currentTab={currentTab}
|
||||
user={data ? data.me : null}
|
||||
user={data ? data.me.user : null}
|
||||
canEditProjectName={userIsTeamOrProjectAdmin}
|
||||
canInviteUser={userIsTeamOrProjectAdmin}
|
||||
onMemberProfile={onMemberProfile}
|
||||
onInviteUser={onInviteUser}
|
||||
onChangeRole={onChangeRole}
|
||||
onChangeProjectOwner={onChangeProjectOwner}
|
||||
|
5
frontend/src/App/cache.ts
Normal file
5
frontend/src/App/cache.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
|
||||
const cache = new InMemoryCache();
|
||||
|
||||
export default cache;
|
@ -1,9 +1,80 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
type UserIDContextState = {
|
||||
userID: string | null;
|
||||
setUserID: (userID: string | null) => void;
|
||||
export enum PermissionLevel {
|
||||
ORG,
|
||||
TEAM,
|
||||
PROJECT,
|
||||
}
|
||||
|
||||
export enum PermissionObjectType {
|
||||
ORG,
|
||||
TEAM,
|
||||
PROJECT,
|
||||
TASK,
|
||||
}
|
||||
|
||||
export type CurrentUserRoles = {
|
||||
org: string;
|
||||
teams: Map<string, string>;
|
||||
projects: Map<string, string>;
|
||||
};
|
||||
export const UserIDContext = React.createContext<UserIDContextState>({ userID: null, setUserID: _userID => null });
|
||||
|
||||
export default UserIDContext;
|
||||
export interface CurrentUserRaw {
|
||||
id: string;
|
||||
roles: CurrentUserRoles;
|
||||
}
|
||||
|
||||
type UserContextState = {
|
||||
user: CurrentUserRaw | null;
|
||||
setUser: (user: CurrentUserRaw | null) => void;
|
||||
setUserRoles: (roles: CurrentUserRoles) => void;
|
||||
};
|
||||
export const UserContext = React.createContext<UserContextState>({
|
||||
user: null,
|
||||
setUser: _user => null,
|
||||
setUserRoles: roles => null,
|
||||
});
|
||||
|
||||
export interface CurrentUser extends CurrentUserRaw {
|
||||
isAdmin: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
|
||||
isVisible: (level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) => boolean;
|
||||
}
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
const { user, setUser, setUserRoles } = useContext(UserContext);
|
||||
let currentUser: CurrentUser | null = null;
|
||||
if (user) {
|
||||
currentUser = {
|
||||
...user,
|
||||
isAdmin(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
|
||||
if (user.roles.org === 'admin') {
|
||||
return true;
|
||||
}
|
||||
switch (level) {
|
||||
case PermissionLevel.TEAM:
|
||||
return subjectID ? this.roles.teams.get(subjectID) === 'admin' : false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isVisible(level: PermissionLevel, objectType: PermissionObjectType, subjectID?: string | null) {
|
||||
if (user.roles.org === 'admin') {
|
||||
return true;
|
||||
}
|
||||
switch (level) {
|
||||
case PermissionLevel.TEAM:
|
||||
return subjectID ? this.roles.teams.get(subjectID) !== null : false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
user: currentUser,
|
||||
setUser,
|
||||
setUserRoles,
|
||||
};
|
||||
};
|
||||
|
||||
export default UserContext;
|
||||
|
@ -9,8 +9,7 @@ import NormalizeStyles from './NormalizeStyles';
|
||||
import BaseStyles from './BaseStyles';
|
||||
import { theme } from './ThemeStyles';
|
||||
import Routes from './Routes';
|
||||
import { UserIDContext } from './context';
|
||||
import Navbar from './Navbar';
|
||||
import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context';
|
||||
|
||||
const history = createBrowserHistory();
|
||||
type RefreshTokenResponse = {
|
||||
@ -20,7 +19,15 @@ type RefreshTokenResponse = {
|
||||
|
||||
const App = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userID, setUserID] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<CurrentUserRaw | null>(null);
|
||||
const setUserRoles = (roles: CurrentUserRoles) => {
|
||||
if (user) {
|
||||
setUser({
|
||||
...user,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/auth/refresh_token', {
|
||||
@ -34,7 +41,11 @@ const App = () => {
|
||||
const response: RefreshTokenResponse = await x.json();
|
||||
const { accessToken, isInstalled } = response;
|
||||
const claims: JWTToken = jwtDecode(accessToken);
|
||||
setUserID(claims.userId);
|
||||
const currentUser = {
|
||||
id: claims.userId,
|
||||
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
|
||||
};
|
||||
setUser(currentUser);
|
||||
setAccessToken(accessToken);
|
||||
if (!isInstalled) {
|
||||
history.replace('/install');
|
||||
@ -46,7 +57,7 @@ const App = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserIDContext.Provider value={{ userID, setUserID }}>
|
||||
<UserContext.Provider value={{ user, setUser, setUserRoles }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
@ -62,7 +73,7 @@ const App = () => {
|
||||
</PopupProvider>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
</UserIDContext.Provider>
|
||||
</UserContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user