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

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useContext } from 'react';
import Admin from 'shared/components/Admin'; import Admin from 'shared/components/Admin';
import Select from 'shared/components/Select'; import Select from 'shared/components/Select';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
@ -16,6 +16,8 @@ import { useForm, Controller } from 'react-hook-form';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import UserContext, { useCurrentUser } from 'App/context';
import { Redirect } from 'react-router';
const DeleteUserWrapper = styled.div` const DeleteUserWrapper = styled.div`
display: flex; display: flex;
@ -170,6 +172,7 @@ const AdminRoute = () => {
}, []); }, []);
const { loading, data } = useUsersQuery(); const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser();
const [deleteUser] = useDeleteUserAccountMutation({ const [deleteUser] = useDeleteUserAccountMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache => updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
@ -201,13 +204,17 @@ const AdminRoute = () => {
if (loading) { if (loading) {
return <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />; return <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />;
} }
if (data) { if (data && user) {
if (user.roles.org != 'admin') {
return <Redirect to="/" />;
}
return ( return (
<> <>
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} /> <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
<Admin <Admin
initialTab={0} initialTab={0}
users={data.users} users={data.users}
canInviteUser={user.roles.org == 'admin'}
onInviteUser={() => {}} onInviteUser={() => {}}
onUpdateUserPassword={(user, password) => { onUpdateUserPassword={(user, password) => {
console.log(user); console.log(user);

View File

@ -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;

View File

@ -4,7 +4,7 @@ import styled from 'styled-components/macro';
import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu'; import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu';
import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings'; import ProjectSettings, { DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import UserIDContext from 'App/context'; import { UserContext, PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import { import {
RoleCode, RoleCode,
useMeQuery, useMeQuery,
@ -16,6 +16,8 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history'; import { History } from 'history';
import produce from 'immer'; import produce from 'immer';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import MiniProfile from 'shared/components/MiniProfile';
import cache from 'App/cache';
const TeamContainer = styled.div` const TeamContainer = styled.div`
display: flex; display: flex;
@ -221,6 +223,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({ history, name, proje
type GlobalTopNavbarProps = { type GlobalTopNavbarProps = {
nameOnly?: boolean; nameOnly?: boolean;
projectID: string | null; projectID: string | null;
teamID?: string | null;
onChangeProjectOwner?: (userID: string) => void; onChangeProjectOwner?: (userID: string) => void;
name: string | null; name: string | null;
currentTab?: number; currentTab?: number;
@ -239,6 +242,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
onSetTab, onSetTab,
menuType, menuType,
projectID, projectID,
teamID,
onChangeProjectOwner, onChangeProjectOwner,
onChangeRole, onChangeRole,
name, name,
@ -250,10 +254,27 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
nameOnly, nameOnly,
}) => { }) => {
console.log(popupContent); 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 { showPopup, hidePopup, setTab } = usePopup();
const history = useHistory(); const history = useHistory();
const { userID, setUserID } = useContext(UserIDContext);
const onLogout = () => { const onLogout = () => {
fetch('/auth/logout', { fetch('/auth/logout', {
method: 'POST', method: 'POST',
@ -261,8 +282,9 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
}).then(async x => { }).then(async x => {
const { status } = x; const { status } = x;
if (status === 200) { if (status === 200) {
cache.reset();
history.replace('/login'); history.replace('/login');
setUserID(null); setUser(null);
hidePopup(); hidePopup();
} }
}); });
@ -273,6 +295,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
<Popup title={null} tab={0}> <Popup title={null} tab={0}>
<ProfileMenu <ProfileMenu
onLogout={onLogout} onLogout={onLogout}
showAdminConsole={user ? user.roles.org === 'admin' : false}
onAdminConsole={() => { onAdminConsole={() => {
history.push('/admin'); history.push('/admin');
hidePopup(); hidePopup();
@ -295,9 +318,41 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
} }
}; };
if (!userID) { if (!user) {
return null; 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 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) {
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 ( return (
<> <>
<TopNavbar <TopNavbar
@ -312,7 +367,10 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
); );
}} }}
currentTab={currentTab} currentTab={currentTab}
user={data ? data.me : null} user={data ? data.me.user : null}
canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile}
onInviteUser={onInviteUser} onInviteUser={onInviteUser}
onChangeRole={onChangeRole} onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner} onChangeProjectOwner={onChangeProjectOwner}

View File

@ -0,0 +1,5 @@
import { InMemoryCache } from 'apollo-cache-inmemory';
const cache = new InMemoryCache();
export default cache;

View File

@ -1,9 +1,80 @@
import React from 'react'; import React, { useContext } from 'react';
type UserIDContextState = { export enum PermissionLevel {
userID: string | null; ORG,
setUserID: (userID: string | null) => void; 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;

View File

@ -9,8 +9,7 @@ import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles'; import BaseStyles from './BaseStyles';
import { theme } from './ThemeStyles'; import { theme } from './ThemeStyles';
import Routes from './Routes'; import Routes from './Routes';
import { UserIDContext } from './context'; import { UserContext, CurrentUserRaw, CurrentUserRoles, PermissionLevel, PermissionObjectType } from './context';
import Navbar from './Navbar';
const history = createBrowserHistory(); const history = createBrowserHistory();
type RefreshTokenResponse = { type RefreshTokenResponse = {
@ -20,7 +19,15 @@ type RefreshTokenResponse = {
const App = () => { const App = () => {
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
fetch('/auth/refresh_token', { fetch('/auth/refresh_token', {
@ -34,7 +41,11 @@ const App = () => {
const response: RefreshTokenResponse = await x.json(); const response: RefreshTokenResponse = await x.json();
const { accessToken, isInstalled } = response; const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken); 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); setAccessToken(accessToken);
if (!isInstalled) { if (!isInstalled) {
history.replace('/install'); history.replace('/install');
@ -46,7 +57,7 @@ const App = () => {
return ( return (
<> <>
<UserIDContext.Provider value={{ userID, setUserID }}> <UserContext.Provider value={{ user, setUser, setUserRoles }}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NormalizeStyles /> <NormalizeStyles />
<BaseStyles /> <BaseStyles />
@ -62,7 +73,7 @@ const App = () => {
</PopupProvider> </PopupProvider>
</Router> </Router>
</ThemeProvider> </ThemeProvider>
</UserIDContext.Provider> </UserContext.Provider>
</> </>
); );
}; };

View File

@ -6,13 +6,13 @@ import { setAccessToken } from 'shared/utils/accessToken';
import Login from 'shared/components/Login'; import Login from 'shared/components/Login';
import { Container, LoginWrapper } from './Styles'; import { Container, LoginWrapper } from './Styles';
import UserIDContext from 'App/context'; import UserContext, { PermissionLevel, PermissionObjectType } from 'App/context';
import JwtDecode from 'jwt-decode'; import JwtDecode from 'jwt-decode';
const Auth = () => { const Auth = () => {
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0); const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
const history = useHistory(); const history = useHistory();
const { setUserID } = useContext(UserIDContext); const { setUser } = useContext(UserContext);
const login = ( const login = (
data: LoginFormData, data: LoginFormData,
setComplete: (val: boolean) => void, setComplete: (val: boolean) => void,
@ -35,7 +35,11 @@ const Auth = () => {
const response = await x.json(); const response = await x.json();
const { accessToken } = response; const { accessToken } = response;
const claims: JWTToken = JwtDecode(accessToken); 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);
setComplete(true); setComplete(true);
setAccessToken(accessToken); setAccessToken(accessToken);

View File

@ -8,13 +8,13 @@ import { getAccessToken, setAccessToken } from 'shared/utils/accessToken';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import produce from 'immer'; import produce from 'immer';
import { useApolloClient } from '@apollo/react-hooks'; import { useApolloClient } from '@apollo/react-hooks';
import UserIDContext from 'App/context'; import UserContext, { PermissionLevel, PermissionObjectType } from 'App/context';
import jwtDecode from 'jwt-decode'; import jwtDecode from 'jwt-decode';
const Install = () => { const Install = () => {
const client = useApolloClient(); const client = useApolloClient();
const history = useHistory(); const history = useHistory();
const { setUserID } = useContext(UserIDContext); const { setUser } = useContext(UserContext);
useEffect(() => { useEffect(() => {
fetch('/auth/refresh_token', { fetch('/auth/refresh_token', {
method: 'POST', method: 'POST',
@ -65,7 +65,15 @@ const Install = () => {
const response: RefreshTokenResponse = await x.data; const response: RefreshTokenResponse = await x.data;
const { accessToken, isInstalled } = response; const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken); 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); setAccessToken(accessToken);
if (!isInstalled) { if (!isInstalled) {
history.replace('/install'); history.replace('/install');

View File

@ -3,9 +3,7 @@ import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getAccessToken } from 'shared/utils/accessToken'; import { getAccessToken } from 'shared/utils/accessToken';
import Navbar from 'App/Navbar';
import Settings from 'shared/components/Settings'; import Settings from 'shared/components/Settings';
import UserIDContext from 'App/context';
import { useMeQuery, useClearProfileAvatarMutation } from 'shared/generated/graphql'; import { useMeQuery, useClearProfileAvatarMutation } from 'shared/generated/graphql';
import axios from 'axios'; import axios from 'axios';
@ -53,7 +51,7 @@ const Projects = () => {
<GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} /> <GlobalTopNavbar projectID={null} onSaveProjectName={() => {}} name={null} />
{!loading && data && ( {!loading && data && (
<Settings <Settings
profile={data.me} profile={data.me.user}
onProfileAvatarChange={() => { onProfileAvatarChange={() => {
if ($fileUpload && $fileUpload.current) { if ($fileUpload && $fileUpload.current) {
$fileUpload.current.click(); $fileUpload.current.click();

View File

@ -8,7 +8,6 @@ import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom'; import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
import { import {
useSetProjectOwnerMutation,
useUpdateProjectMemberRoleMutation, useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation, useCreateProjectMemberMutation,
useDeleteProjectMemberMutation, useDeleteProjectMemberMutation,
@ -44,7 +43,7 @@ import SimpleLists from 'shared/components/Lists';
import produce from 'immer'; import produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager'; import DueDateManager from 'shared/components/DueDateManager';
import UserIDContext from 'App/context'; import UserContext, { useCurrentUser } from 'App/context';
import LabelManager from 'shared/components/PopupMenu/LabelManager'; import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor'; import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
import EmptyBoard from 'shared/components/EmptyBoard'; import EmptyBoard from 'shared/components/EmptyBoard';
@ -106,174 +105,10 @@ const initialQuickCardEditorState: QuickCardEditorState = {
type ProjectBoardProps = { type ProjectBoardProps = {
onCardLabelClick?: () => void; onCardLabelClick?: () => void;
cardLabelVariant?: CardLabelVariant; cardLabelVariant?: CardLabelVariant;
projectID?: string; projectID: string;
loading?: boolean;
}; };
const ProjectBoard: React.FC<ProjectBoardProps> = ({ export const BoardLoading = () => {
projectID,
onCardLabelClick,
cardLabelVariant,
loading: isLoading = false,
}) => {
const [assignTask] = useAssignTaskMutation();
const [unassignTask] = useUnassignTaskMutation();
const $labelsRef = useRef<HTMLDivElement>(null);
const match = useRouteMatch();
const labelsRef = useRef<Array<ProjectLabel>>([]);
const { showPopup, hidePopup } = usePopup();
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
const { userID } = useContext(UserIDContext);
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
const history = useHistory();
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
update: (client, deletedTaskGroupData) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
);
}),
{ projectId: projectID },
);
},
});
const [updateTaskName] = useUpdateTaskNameMutation();
const [createTask] = useCreateTaskMutation({
update: (client, newTaskData) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
}
}),
{ projectId: projectID },
);
},
});
const [createTaskGroup] = useCreateTaskGroupMutation({
update: (client, newTaskGroupData) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
}),
{ projectId: projectID },
);
},
});
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
const { loading, data } = useFindProjectQuery({
variables: { projectId: projectID ?? '' },
});
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskLocation] = useUpdateTaskLocationMutation({
update: (client, newTask) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) {
const { taskGroups } = cache.findProject;
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id,
);
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{ ...task },
];
}
}
}),
{ projectId: projectID },
);
},
});
const [deleteTask] = useDeleteTaskMutation();
const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
console.log(taskLabelsRef.current);
},
});
const onCreateTask = (taskGroupID: string, name: string) => {
if (data) {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
console.log(`taskGroup ${taskGroup}`);
if (taskGroup) {
let position = 65535;
if (taskGroup.tasks.length !== 0) {
const [lastTask] = taskGroup.tasks
.slice()
.sort((a: any, b: any) => a.position - b.position)
.slice(-1);
position = Math.ceil(lastTask.position) * 2 + 1;
}
console.log(`position ${position}`);
createTask({
variables: { taskGroupID, name, position },
optimisticResponse: {
__typename: 'Mutation',
createTask: {
__typename: 'Task',
id: '' + Math.round(Math.random() * -1000000),
name,
complete: false,
taskGroup: {
__typename: 'TaskGroup',
id: taskGroup.id,
name: taskGroup.name,
position: taskGroup.position,
},
badges: {
checklist: null,
},
position,
dueDate: null,
description: null,
labels: [],
assigned: [],
},
},
});
}
}
};
const onCreateList = (listName: string) => {
if (data && projectID) {
const [lastColumn] = data.findProject.taskGroups.sort((a, b) => a.position - b.position).slice(-1);
let position = 65535;
if (lastColumn) {
position = lastColumn.position * 2 + 1;
}
createTaskGroup({ variables: { projectID, name: listName, position } });
}
};
if (loading || isLoading) {
return ( return (
<> <>
<ProjectBar> <ProjectBar>
@ -309,6 +144,169 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({
<EmptyBoard /> <EmptyBoard />
</> </>
); );
};
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
const [assignTask] = useAssignTaskMutation();
const [unassignTask] = useUnassignTaskMutation();
const $labelsRef = useRef<HTMLDivElement>(null);
const match = useRouteMatch();
const labelsRef = useRef<Array<ProjectLabel>>([]);
const { showPopup, hidePopup } = usePopup();
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
const { user } = useCurrentUser();
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
const history = useHistory();
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
update: (client, deletedTaskGroupData) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups = draftCache.findProject.taskGroups.filter(
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
);
}),
{ projectID },
);
},
});
const [updateTaskName] = useUpdateTaskNameMutation();
const [createTask] = useCreateTaskMutation({
update: (client, newTaskData) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
}
}),
{ projectID },
);
},
});
const [createTaskGroup] = useCreateTaskGroupMutation({
update: (client, newTaskGroupData) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
}),
{ projectID },
);
},
});
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
const { loading, data } = useFindProjectQuery({
variables: { projectID },
});
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskLocation] = useUpdateTaskLocationMutation({
update: (client, newTask) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) {
const { taskGroups } = cache.findProject;
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
draftCache.findProject.taskGroups[oldTaskGroupIdx].tasks = taskGroups[oldTaskGroupIdx].tasks.filter(
(t: Task) => t.id !== task.id,
);
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{ ...task },
];
}
}
}),
{ projectID },
);
},
});
const [deleteTask] = useDeleteTaskMutation();
const [toggleTaskLabel] = useToggleTaskLabelMutation({
onCompleted: newTaskLabel => {
taskLabelsRef.current = newTaskLabel.toggleTaskLabel.task.labels;
console.log(taskLabelsRef.current);
},
});
const onCreateTask = (taskGroupID: string, name: string) => {
if (data) {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
console.log(`taskGroup ${taskGroup}`);
if (taskGroup) {
let position = 65535;
if (taskGroup.tasks.length !== 0) {
const [lastTask] = taskGroup.tasks
.slice()
.sort((a: any, b: any) => a.position - b.position)
.slice(-1);
position = Math.ceil(lastTask.position) * 2 + 1;
}
console.log(`position ${position}`);
createTask({
variables: { taskGroupID, name, position },
optimisticResponse: {
__typename: 'Mutation',
createTask: {
__typename: 'Task',
id: `${Math.round(Math.random() * -1000000)}`,
name,
complete: false,
taskGroup: {
__typename: 'TaskGroup',
id: taskGroup.id,
name: taskGroup.name,
position: taskGroup.position,
},
badges: {
__typename: 'TaskBadges',
checklist: null,
},
position,
dueDate: null,
description: null,
labels: [],
assigned: [],
},
},
});
}
}
};
const onCreateList = (listName: string) => {
if (data && projectID) {
const [lastColumn] = data.findProject.taskGroups.sort((a, b) => a.position - b.position).slice(-1);
let position = 65535;
if (lastColumn) {
position = lastColumn.position * 2 + 1;
}
createTaskGroup({ variables: { projectID, name: listName, position } });
}
};
if (loading) {
return <BoardLoading />;
} }
if (data) { if (data) {
labelsRef.current = data.findProject.labels; labelsRef.current = data.findProject.labels;
@ -534,7 +532,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({
tasks: taskGroup.tasks.filter(t => t.id !== cardId), tasks: taskGroup.tasks.filter(t => t.id !== cardId),
})); }));
}), }),
{ projectId: projectID }, { projectID },
); );
}, },
}) })

View File

@ -22,7 +22,7 @@ import {
FindTaskDocument, FindTaskDocument,
FindTaskQuery, FindTaskQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import UserIDContext from 'App/context'; import UserContext, { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager'; import DueDateManager from 'shared/components/DueDateManager';
import produce from 'immer'; import produce from 'immer';
@ -129,7 +129,7 @@ const Details: React.FC<DetailsProps> = ({
availableMembers, availableMembers,
refreshCache, refreshCache,
}) => { }) => {
const { userID } = useContext(UserIDContext); const { user } = useCurrentUser();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const history = useHistory(); const history = useHistory();
const match = useRouteMatch(); const match = useRouteMatch();
@ -407,7 +407,9 @@ const Details: React.FC<DetailsProps> = ({
user={member} user={member}
bio="None" bio="None"
onRemoveFromTask={() => { onRemoveFromTask={() => {
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); if (user) {
unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
}
}} }}
/> />
</Popup>, </Popup>,
@ -422,10 +424,12 @@ const Details: React.FC<DetailsProps> = ({
availableMembers={availableMembers} availableMembers={availableMembers}
activeMembers={data.findTask.assigned} activeMembers={data.findTask.assigned}
onMemberChange={(member, isActive) => { onMemberChange={(member, isActive) => {
if (user) {
if (isActive) { if (isActive) {
assignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); assignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
} else { } else {
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } }); unassignTask({ variables: { taskID: data.findTask.id, userID: user.id } });
}
} }
}} }}
/> />

View File

@ -3,7 +3,6 @@ import updateApolloCache from 'shared/utils/cache';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer'; import produce from 'immer';
import { import {
useSetProjectOwnerMutation,
useUpdateProjectMemberRoleMutation, useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation, useCreateProjectMemberMutation,
useDeleteProjectMemberMutation, useDeleteProjectMemberMutation,
@ -61,7 +60,7 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel }); draftCache.findProject.labels.push({ ...newLabelData.data.createProjectLabel });
}), }),
{ {
projectId: projectID, projectID,
}, },
); );
}, },
@ -78,7 +77,7 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
label => label.id !== newLabelData.data.deleteProjectLabel.id, label => label.id !== newLabelData.data.deleteProjectLabel.id,
); );
}), }),
{projectId: projectID}, { projectID },
); );
}, },
}); });
@ -151,4 +150,4 @@ const LabelManagerEditor: React.FC<LabelManagerEditorProps> = ({
); );
}; };
export default LabelManagerEditor export default LabelManagerEditor;

View File

@ -15,7 +15,6 @@ import {
Redirect, Redirect,
} from 'react-router-dom'; } from 'react-router-dom';
import { import {
useSetProjectOwnerMutation,
useUpdateProjectMemberRoleMutation, useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation, useCreateProjectMemberMutation,
useDeleteProjectMemberMutation, useDeleteProjectMemberMutation,
@ -34,10 +33,10 @@ import {
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import produce from 'immer'; import produce from 'immer';
import UserIDContext from 'App/context'; import UserContext, { useCurrentUser } from 'App/context';
import Input from 'shared/components/Input'; import Input from 'shared/components/Input';
import Member from 'shared/components/Member'; import Member from 'shared/components/Member';
import Board from './Board'; import Board, { BoardLoading } from './Board';
import Details from './Details'; import Details from './Details';
import EmptyBoard from 'shared/components/EmptyBoard'; import EmptyBoard from 'shared/components/EmptyBoard';
@ -140,7 +139,7 @@ const Project = () => {
const [updateTaskName] = useUpdateTaskNameMutation(); const [updateTaskName] = useUpdateTaskNameMutation();
const { loading, data } = useFindProjectQuery({ const { loading, data } = useFindProjectQuery({
variables: { projectId: projectID }, variables: { projectID },
}); });
const [updateProjectName] = useUpdateProjectNameMutation({ const [updateProjectName] = useUpdateProjectNameMutation({
@ -152,7 +151,7 @@ const Project = () => {
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.name = newName.data.updateProjectName.name; draftCache.findProject.name = newName.data.updateProjectName.name;
}), }),
{ projectId: projectID }, { projectID },
); );
}, },
}); });
@ -166,11 +165,10 @@ const Project = () => {
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findProject.members.push({ ...response.data.createProjectMember.member }); draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
}), }),
{ projectId: projectID }, { projectID },
); );
}, },
}); });
const [setProjectOwner] = useSetProjectOwnerMutation();
const [deleteProjectMember] = useDeleteProjectMemberMutation({ const [deleteProjectMember] = useDeleteProjectMemberMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
@ -184,12 +182,12 @@ const Project = () => {
m => m.id !== response.data.deleteProjectMember.member.id, m => m.id !== response.data.deleteProjectMember.member.id,
); );
}), }),
{ projectId: projectID }, { projectID },
); );
}, },
}); });
const { userID } = useContext(UserIDContext); const { user } = useCurrentUser();
const location = useLocation(); const location = useLocation();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
@ -205,7 +203,7 @@ const Project = () => {
return ( return (
<> <>
<GlobalTopNavbar onSaveProjectName={projectName => {}} name="" projectID={null} /> <GlobalTopNavbar onSaveProjectName={projectName => {}} name="" projectID={null} />
<Board loading /> <BoardLoading />
</> </>
); );
} }
@ -221,7 +219,6 @@ const Project = () => {
updateProjectMemberRole({ variables: { userID, roleCode, projectID } }); updateProjectMemberRole({ variables: { userID, roleCode, projectID } });
}} }}
onChangeProjectOwner={uid => { onChangeProjectOwner={uid => {
setProjectOwner({ variables: { ownerID: uid, projectID } });
hidePopup(); hidePopup();
}} }}
onRemoveFromBoard={userID => { onRemoveFromBoard={userID => {
@ -248,6 +245,7 @@ const Project = () => {
currentTab={0} currentTab={0}
projectMembers={data.findProject.members} projectMembers={data.findProject.members}
projectID={projectID} projectID={projectID}
teamID={data.findProject.team.id}
name={data.findProject.name} name={data.findProject.name}
/> />
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} /> <Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />

View File

@ -12,9 +12,8 @@ import {
import ProjectGridItem, { AddProjectItem } from 'shared/components/ProjectGridItem'; import ProjectGridItem, { AddProjectItem } from 'shared/components/ProjectGridItem';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Navbar from 'App/Navbar';
import NewProject from 'shared/components/NewProject'; import NewProject from 'shared/components/NewProject';
import UserIDContext from 'App/context'; import UserContext, { PermissionLevel, PermissionObjectType, useCurrentUser } from 'App/context';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import { usePopup, Popup } from 'shared/components/PopupMenu'; import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -227,7 +226,7 @@ const ProjectLink = styled(Link)``;
const Projects = () => { const Projects = () => {
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery(); const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
useEffect(() => { useEffect(() => {
document.title = 'Taskcafé'; document.title = 'Taskcafé';
}, []); }, []);
@ -242,7 +241,7 @@ const Projects = () => {
}); });
const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null }); const [showNewProject, setShowNewProject] = useState<ShowNewProject>({ open: false, initialTeamID: null });
const { userID, setUserID } = useContext(UserIDContext); const { user, setUser } = useCurrentUser();
const [createTeam] = useCreateTeamMutation({ const [createTeam] = useCreateTeamMutation({
update: (client, createData) => { update: (client, createData) => {
updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache => updateApolloCache<GetProjectsQuery>(client, GetProjectsDocument, cache =>
@ -261,21 +260,36 @@ const Projects = () => {
} }
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f']; const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];
if (data) { if (data && user) {
console.log(user);
const { projects, teams, organizations } = data; const { projects, teams, organizations } = data;
const organizationID = organizations[0].id ?? null; const organizationID = organizations[0].id ?? null;
const projectTeams = teams.map(team => { const projectTeams = teams
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
})
.map(team => {
return { return {
id: team.id, id: team.id,
name: team.name, name: team.name,
projects: projects.filter(project => project.team.id === team.id), projects: projects
.filter(project => project.team.id === team.id)
.sort((a, b) => {
const textA = a.name.toUpperCase();
const textB = b.name.toUpperCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
}),
}; };
}); });
console.log(projectTeams);
return ( return (
<> <>
<GlobalTopNavbar onSaveProjectName={() => {}} projectID={null} name={null} /> <GlobalTopNavbar onSaveProjectName={() => {}} projectID={null} name={null} />
<Wrapper> <Wrapper>
<ProjectsContainer> <ProjectsContainer>
{user.roles.org === 'admin' && (
<AddTeamButton <AddTeamButton
variant="outline" variant="outline"
onClick={$target => { onClick={$target => {
@ -302,6 +316,7 @@ const Projects = () => {
> >
Add Team Add Team
</AddTeamButton> </AddTeamButton>
)}
{projectTeams.length === 0 && ( {projectTeams.length === 0 && (
<EmptyStateContent> <EmptyStateContent>
<EmptyState width={425} height={425} /> <EmptyState width={425} height={425} />
@ -340,6 +355,7 @@ const Projects = () => {
<div key={team.id}> <div key={team.id}>
<ProjectSectionTitleWrapper> <ProjectSectionTitleWrapper>
<ProjectSectionTitle>{team.name}</ProjectSectionTitle> <ProjectSectionTitle>{team.name}</ProjectSectionTitle>
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
<SectionActions> <SectionActions>
<SectionActionLink to={`/teams/${team.id}`}> <SectionActionLink to={`/teams/${team.id}`}>
<SectionAction variant="outline">Projects</SectionAction> <SectionAction variant="outline">Projects</SectionAction>
@ -351,6 +367,7 @@ const Projects = () => {
<SectionAction variant="outline">Settings</SectionAction> <SectionAction variant="outline">Settings</SectionAction>
</SectionActionLink> </SectionActionLink>
</SectionActions> </SectionActions>
)}
</ProjectSectionTitleWrapper> </ProjectSectionTitleWrapper>
<ProjectList> <ProjectList>
{team.projects.map((project, idx) => ( {team.projects.map((project, idx) => (
@ -363,6 +380,7 @@ const Projects = () => {
</ProjectTile> </ProjectTile>
</ProjectListItem> </ProjectListItem>
))} ))}
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, team.id) && (
<ProjectListItem> <ProjectListItem>
<ProjectAddTile <ProjectAddTile
onClick={() => { onClick={() => {
@ -375,6 +393,7 @@ const Projects = () => {
</ProjectAddTileDetails> </ProjectAddTileDetails>
</ProjectAddTile> </ProjectAddTile>
</ProjectListItem> </ProjectListItem>
)}
</ProjectList> </ProjectList>
</div> </div>
); );
@ -383,8 +402,8 @@ const Projects = () => {
<NewProject <NewProject
initialTeamID={showNewProject.initialTeamID} initialTeamID={showNewProject.initialTeamID}
onCreateProject={(name, teamID) => { onCreateProject={(name, teamID) => {
if (userID) { if (user) {
createProject({ variables: { teamID, name, userID } }); createProject({ variables: { teamID, name, userID: user.id } });
setShowNewProject({ open: false, initialTeamID: null }); setShowNewProject({ open: false, initialTeamID: null });
} }
}} }}

View File

@ -3,15 +3,18 @@ import Input from 'shared/components/Input';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import produce from 'immer'; import produce from 'immer';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import UserIDContext from 'App/context'; import UserContext, { useCurrentUser, PermissionLevel, PermissionObjectType } from 'App/context';
import Select from 'shared/components/Select'; import Select from 'shared/components/Select';
import { import {
useGetTeamQuery, useGetTeamQuery,
RoleCode, RoleCode,
useCreateTeamMemberMutation, useCreateTeamMemberMutation,
useDeleteTeamMemberMutation, useDeleteTeamMemberMutation,
useUpdateTeamMemberRoleMutation,
GetTeamQuery, GetTeamQuery,
GetTeamDocument, GetTeamDocument,
MeDocument,
MeQuery,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { UserPlus, Checkmark } from 'shared/icons'; import { UserPlus, Checkmark } from 'shared/icons';
import styled, { css } from 'styled-components/macro'; import styled, { css } from 'styled-components/macro';
@ -165,7 +168,6 @@ type TeamRoleManagerPopupProps = {
canChangeRole: boolean; canChangeRole: boolean;
onChangeRole: (roleCode: RoleCode) => void; onChangeRole: (roleCode: RoleCode) => void;
onRemoveFromTeam?: (newOwnerID: string | null) => void; onRemoveFromTeam?: (newOwnerID: string | null) => void;
onChangeTeamOwner?: (userID: string) => void;
}; };
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
@ -175,7 +177,6 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
currentUserID, currentUserID,
canChangeRole, canChangeRole,
onRemoveFromTeam, onRemoveFromTeam,
onChangeTeamOwner,
onChangeRole, onChangeRole,
}) => { }) => {
const { hidePopup, setTab } = usePopup(); const { hidePopup, setTab } = usePopup();
@ -185,15 +186,6 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
<Popup title={null} tab={0}> <Popup title={null} tab={0}>
<MiniProfileActions> <MiniProfileActions>
<MiniProfileActionWrapper> <MiniProfileActionWrapper>
{onChangeTeamOwner && (
<MiniProfileActionItem
onClick={() => {
setTab(3);
}}
>
Set as team owner...
</MiniProfileActionItem>
)}
{subject.role && ( {subject.role && (
<MiniProfileActionItem <MiniProfileActionItem
onClick={() => { onClick={() => {
@ -298,24 +290,6 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
</RemoveMemberButton> </RemoveMemberButton>
</Content> </Content>
</Popup> </Popup>
<Popup title="Set as Team Owner?" onClose={() => hidePopup()} tab={3}>
<Content>
<DeleteDescription>
This will change the project owner from you to this subject. 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 (onChangeTeamOwner) {
onChangeTeamOwner(subject.id);
}
}}
>
Set as Project Owner
</RemoveMemberButton>
</Content>
</Popup>
</> </>
); );
}; };
@ -444,7 +418,7 @@ type MembersProps = {
const Members: React.FC<MembersProps> = ({ teamID }) => { const Members: React.FC<MembersProps> = ({ teamID }) => {
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetTeamQuery({ variables: { teamID } }); const { loading, data } = useGetTeamQuery({ variables: { teamID } });
const { userID } = useContext(UserIDContext); const { user, setUserRoles } = useCurrentUser();
const warning = 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”.'; 'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
const [createTeamMember] = useCreateTeamMemberMutation({ const [createTeamMember] = useCreateTeamMemberMutation({
@ -454,12 +428,27 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
GetTeamDocument, GetTeamDocument,
cache => cache =>
produce(cache, draftCache => { produce(cache, draftCache => {
draftCache.findTeam.members.push({ ...response.data.createTeamMember.teamMember }); draftCache.findTeam.members.push({
...response.data.createTeamMember.teamMember,
member: { __typename: 'MemberList', projects: [], teams: [] },
owned: { __typename: 'OwnedList', projects: [], teams: [] },
});
}), }),
{ teamID }, { teamID },
); );
}, },
}); });
const [updateTeamMemberRole] = useUpdateTeamMemberRoleMutation({
onCompleted: r => {
if (user) {
setUserRoles(
produce(user.roles, draftRoles => {
draftRoles.teams.set(r.updateTeamMemberRole.teamID, r.updateTeamMemberRole.member.role.code);
}),
);
}
},
});
const [deleteTeamMember] = useDeleteTeamMemberMutation({ const [deleteTeamMember] = useDeleteTeamMemberMutation({
update: (client, response) => { update: (client, response) => {
updateApolloCache<GetTeamQuery>( updateApolloCache<GetTeamQuery>(
@ -479,7 +468,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
return <span>loading</span>; return <span>loading</span>;
} }
if (data) { if (data && user) {
return ( return (
<MemberContainer> <MemberContainer>
<FilterTab> <FilterTab>
@ -497,6 +486,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
</ListDesc> </ListDesc>
<ListActions> <ListActions>
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" /> <FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
{user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, data.findTeam.id) && (
<InviteMemberButton <InviteMemberButton
onClick={$target => { onClick={$target => {
showPopup( showPopup(
@ -505,6 +495,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
users={data.users} users={data.users}
teamMembers={data.findTeam.members} teamMembers={data.findTeam.members}
onAddTeamMember={userID => { onAddTeamMember={userID => {
console.log(`team: ${userID}`);
createTeamMember({ variables: { userID, teamID } }); createTeamMember({ variables: { userID, teamID } });
}} }}
/>, />,
@ -514,6 +505,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
<InviteIcon width={16} height={16} /> <InviteIcon width={16} height={16} />
Invite Team Members Invite Team Members
</InviteMemberButton> </InviteMemberButton>
)}
</ListActions> </ListActions>
</MemberListHeader> </MemberListHeader>
<MemberList> <MemberList>
@ -532,15 +524,14 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
showPopup( showPopup(
$target, $target,
<TeamRoleManagerPopup <TeamRoleManagerPopup
currentUserID={userID ?? ''} currentUserID={user.id ?? ''}
subject={member} subject={member}
members={data.findTeam.members} members={data.findTeam.members}
warning={member.role && member.role.code === 'owner' ? warning : null} warning={member.role && member.role.code === 'owner' ? warning : null}
onChangeTeamOwner={ canChangeRole={user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)}
member.role && member.role.code !== 'owner' ? (userID: string) => {} : undefined onChangeRole={roleCode => {
} updateTeamMemberRole({ variables: { userID: member.id, teamID, roleCode } });
canChangeRole={member.role && member.role.code !== 'owner'} }}
onChangeRole={roleCode => {}}
onRemoveFromTeam={ onRemoveFromTeam={
member.role && member.role.code === 'owner' member.role && member.role.code === 'owner'
? undefined ? undefined

View File

@ -3,7 +3,7 @@ import styled, { css } from 'styled-components/macro';
import { MENU_TYPES } from 'shared/components/TopNavbar'; import { MENU_TYPES } from 'shared/components/TopNavbar';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import { Route, Switch, useRouteMatch } from 'react-router'; import { Route, Switch, useRouteMatch, Redirect } from 'react-router';
import Members from './Members'; import Members from './Members';
import Projects from './Projects'; import Projects from './Projects';
@ -18,6 +18,7 @@ import { usePopup, Popup } from 'shared/components/PopupMenu';
import { History } from 'history'; import { History } from 'history';
import produce from 'immer'; import produce from 'immer';
import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings'; import { TeamSettings, DeleteConfirm, DELETE_INFO } from 'shared/components/ProjectSettings';
import UserContext, { PermissionObjectType, PermissionLevel, useCurrentUser } from 'App/context';
const OuterWrapper = styled.div` const OuterWrapper = styled.div`
display: flex; display: flex;
@ -87,6 +88,7 @@ const Teams = () => {
const { teamID } = useParams<TeamsRouteProps>(); const { teamID } = useParams<TeamsRouteProps>();
const history = useHistory(); const history = useHistory();
const { loading, data } = useGetTeamQuery({ variables: { teamID } }); const { loading, data } = useGetTeamQuery({ variables: { teamID } });
const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch(); const match = useRouteMatch();
useEffect(() => { useEffect(() => {
@ -99,7 +101,10 @@ const Teams = () => {
</> </>
); );
} }
if (data) { if (data && user) {
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
return <Redirect to="/" />;
}
return ( return (
<> <>
<GlobalTopNavbar <GlobalTopNavbar

View File

@ -4,14 +4,16 @@ import axios from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh'; import createAuthRefreshInterceptor from 'axios-auth-refresh';
import { ApolloProvider } from '@apollo/react-hooks'; import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client'; import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http'; import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error'; import { onError } from 'apollo-link-error';
import { enableMapSet } from 'immer';
import { ApolloLink, Observable, fromPromise } from 'apollo-link'; import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken'; import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import cache from './App/cache';
import App from './App'; import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8 // https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
enableMapSet();
let forward$; let forward$;
let isRefreshing = false; let isRefreshing = false;
@ -135,7 +137,7 @@ const client = new ApolloClient({
credentials: 'same-origin', credentials: 'same-origin',
}), }),
]), ]),
cache: new InMemoryCache(), cache,
}); });
ReactDOM.render( ReactDOM.render(

View File

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

View File

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

View File

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

View File

@ -50,7 +50,6 @@ type MiniProfileProps = {
onRemoveFromTask?: () => void; onRemoveFromTask?: () => void;
onChangeRole?: (roleCode: RoleCode) => void; onChangeRole?: (roleCode: RoleCode) => void;
onRemoveFromBoard?: () => void; onRemoveFromBoard?: () => void;
onChangeProjectOwner?: (userID: string) => void;
warning?: string | null; warning?: string | null;
canChangeRole?: boolean; canChangeRole?: boolean;
}; };
@ -58,7 +57,6 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
user, user,
bio, bio,
canChangeRole, canChangeRole,
onChangeProjectOwner,
onRemoveFromTask, onRemoveFromTask,
onChangeRole, onChangeRole,
onRemoveFromBoard, onRemoveFromBoard,
@ -91,15 +89,6 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
Remove from card Remove from card
</MiniProfileActionItem> </MiniProfileActionItem>
)} )}
{onChangeProjectOwner && (
<MiniProfileActionItem
onClick={() => {
setTab(3);
}}
>
Set as project owner
</MiniProfileActionItem>
)}
{onChangeRole && user.role && ( {onChangeRole && user.role && (
<MiniProfileActionItem <MiniProfileActionItem
onClick={() => { onClick={() => {
@ -193,24 +182,6 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
</RemoveMemberButton> </RemoveMemberButton>
</Content> </Content>
</Popup> </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 = { type ProjectHeadingProps = {
onFavorite?: () => void; onFavorite?: () => void;
name: string; name: string;
canEditProjectName: boolean;
onSaveProjectName?: (projectName: string) => void; onSaveProjectName?: (projectName: string) => void;
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void; onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
}; };
@ -46,6 +47,7 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
onFavorite, onFavorite,
name: initialProjectName, name: initialProjectName,
onSaveProjectName, onSaveProjectName,
canEditProjectName,
onOpenSettings, onOpenSettings,
}) => { }) => {
const [isEditProjectName, setEditProjectName] = useState(false); const [isEditProjectName, setEditProjectName] = useState(false);
@ -94,7 +96,9 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
) : ( ) : (
<ProjectName <ProjectName
onClick={() => { onClick={() => {
if (canEditProjectName) {
setEditProjectName(true); setEditProjectName(true);
}
}} }}
> >
{projectName} {projectName}
@ -142,19 +146,25 @@ type NavBarProps = {
onProfileClick: ($target: React.RefObject<HTMLElement>) => void; onProfileClick: ($target: React.RefObject<HTMLElement>) => void;
onSaveName?: (name: string) => void; onSaveName?: (name: string) => void;
onNotificationClick: () => void; onNotificationClick: () => void;
canEditProjectName?: boolean;
canInviteUser?: boolean;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void; onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onDashboardClick: () => void; onDashboardClick: () => void;
user: TaskUser | null; user: TaskUser | null;
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void; onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
projectMembers?: Array<TaskUser> | null; projectMembers?: Array<TaskUser> | null;
onRemoveFromBoard?: (userID: string) => void; onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
}; };
const NavBar: React.FC<NavBarProps> = ({ const NavBar: React.FC<NavBarProps> = ({
menuType, menuType,
canInviteUser = false,
onInviteUser, onInviteUser,
onChangeProjectOwner, onChangeProjectOwner,
currentTab, currentTab,
onMemberProfile,
canEditProjectName = false,
onOpenProjectFinder, onOpenProjectFinder,
onFavorite, onFavorite,
onSetTab, onSetTab,
@ -175,47 +185,6 @@ const NavBar: React.FC<NavBarProps> = ({
} }
}; };
const { showPopup } = usePopup(); 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 ( return (
<NavbarWrapper> <NavbarWrapper>
<NavbarHeader> <NavbarHeader>
@ -226,6 +195,7 @@ const NavBar: React.FC<NavBarProps> = ({
onFavorite={onFavorite} onFavorite={onFavorite}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
name={name} name={name}
canEditProjectName={canEditProjectName}
onSaveProjectName={onSaveName} onSaveProjectName={onSaveName}
/> />
)} )}
@ -255,7 +225,7 @@ const NavBar: React.FC<NavBarProps> = ({
<TaskcafeTitle>Taskcafé</TaskcafeTitle> <TaskcafeTitle>Taskcafé</TaskcafeTitle>
</LogoContainer> </LogoContainer>
<GlobalActions> <GlobalActions>
{projectMembers && ( {projectMembers && onMemberProfile && (
<> <>
<ProjectMembers> <ProjectMembers>
{projectMembers.map((member, idx) => ( {projectMembers.map((member, idx) => (
@ -268,6 +238,7 @@ const NavBar: React.FC<NavBarProps> = ({
onMemberProfile={onMemberProfile} onMemberProfile={onMemberProfile}
/> />
))} ))}
{canInviteUser && (
<InviteButton <InviteButton
onClick={$target => { onClick={$target => {
if (onInviteUser) { if (onInviteUser) {
@ -278,6 +249,7 @@ const NavBar: React.FC<NavBarProps> = ({
> >
Invite Invite
</InviteButton> </InviteButton>
)}
</ProjectMembers> </ProjectMembers>
<NavSeparator /> <NavSeparator />
</> </>

View File

@ -17,6 +17,7 @@ export type Scalars = {
export enum RoleCode { export enum RoleCode {
Owner = 'owner', Owner = 'owner',
Admin = 'admin', Admin = 'admin',
@ -125,7 +126,6 @@ export type Project = {
createdAt: Scalars['Time']; createdAt: Scalars['Time'];
name: Scalars['String']; name: Scalars['String'];
team: Team; team: Team;
owner: Member;
taskGroups: Array<TaskGroup>; taskGroups: Array<TaskGroup>;
members: Array<Member>; members: Array<Member>;
labels: Array<ProjectLabel>; labels: Array<ProjectLabel>;
@ -192,6 +192,24 @@ export type TaskChecklist = {
items: Array<TaskChecklistItem>; items: Array<TaskChecklistItem>;
}; };
export enum RoleLevel {
Admin = 'ADMIN',
Member = 'MEMBER'
}
export enum ActionLevel {
Org = 'ORG',
Team = 'TEAM',
Project = 'PROJECT'
}
export enum ObjectType {
Org = 'ORG',
Team = 'TEAM',
Project = 'PROJECT',
Task = 'TASK'
}
export type Query = { export type Query = {
__typename?: 'Query'; __typename?: 'Query';
organizations: Array<Organization>; organizations: Array<Organization>;
@ -204,7 +222,7 @@ export type Query = {
teams: Array<Team>; teams: Array<Team>;
labelColors: Array<LabelColor>; labelColors: Array<LabelColor>;
taskGroups: Array<TaskGroup>; taskGroups: Array<TaskGroup>;
me: UserAccount; me: MePayload;
}; };
@ -260,10 +278,8 @@ export type Mutation = {
deleteUserAccount: DeleteUserAccountPayload; deleteUserAccount: DeleteUserAccountPayload;
logoutUser: Scalars['Boolean']; logoutUser: Scalars['Boolean'];
removeTaskLabel: Task; removeTaskLabel: Task;
setProjectOwner: SetProjectOwnerPayload;
setTaskChecklistItemComplete: TaskChecklistItem; setTaskChecklistItemComplete: TaskChecklistItem;
setTaskComplete: Task; setTaskComplete: Task;
setTeamOwner: SetTeamOwnerPayload;
toggleTaskLabel: ToggleTaskLabelPayload; toggleTaskLabel: ToggleTaskLabelPayload;
unassignTask: Task; unassignTask: Task;
updateProjectLabel: ProjectLabel; updateProjectLabel: ProjectLabel;
@ -412,11 +428,6 @@ export type MutationRemoveTaskLabelArgs = {
}; };
export type MutationSetProjectOwnerArgs = {
input: SetProjectOwner;
};
export type MutationSetTaskChecklistItemCompleteArgs = { export type MutationSetTaskChecklistItemCompleteArgs = {
input: SetTaskChecklistItemComplete; input: SetTaskChecklistItemComplete;
}; };
@ -427,11 +438,6 @@ export type MutationSetTaskCompleteArgs = {
}; };
export type MutationSetTeamOwnerArgs = {
input: SetTeamOwner;
};
export type MutationToggleTaskLabelArgs = { export type MutationToggleTaskLabelArgs = {
input: ToggleTaskLabelInput; input: ToggleTaskLabelInput;
}; };
@ -531,6 +537,25 @@ export type MutationUpdateUserRoleArgs = {
input: UpdateUserRole; input: UpdateUserRole;
}; };
export type TeamRole = {
__typename?: 'TeamRole';
teamID: Scalars['UUID'];
roleCode: RoleCode;
};
export type ProjectRole = {
__typename?: 'ProjectRole';
projectID: Scalars['UUID'];
roleCode: RoleCode;
};
export type MePayload = {
__typename?: 'MePayload';
user: UserAccount;
teamRoles: Array<TeamRole>;
projectRoles: Array<ProjectRole>;
};
export type ProjectsFilter = { export type ProjectsFilter = {
teamID?: Maybe<Scalars['UUID']>; teamID?: Maybe<Scalars['UUID']>;
}; };
@ -540,7 +565,7 @@ export type FindUser = {
}; };
export type FindProject = { export type FindProject = {
projectId: Scalars['String']; projectID: Scalars['UUID'];
}; };
export type FindTask = { export type FindTask = {
@ -633,18 +658,6 @@ export type UpdateProjectMemberRolePayload = {
member: Member; member: Member;
}; };
export type SetProjectOwner = {
projectID: Scalars['UUID'];
ownerID: Scalars['UUID'];
};
export type SetProjectOwnerPayload = {
__typename?: 'SetProjectOwnerPayload';
ok: Scalars['Boolean'];
prevOwner: Member;
newOwner: Member;
};
export type NewTask = { export type NewTask = {
taskGroupID: Scalars['String']; taskGroupID: Scalars['String'];
name: Scalars['String']; name: Scalars['String'];
@ -868,19 +881,8 @@ export type UpdateTeamMemberRole = {
export type UpdateTeamMemberRolePayload = { export type UpdateTeamMemberRolePayload = {
__typename?: 'UpdateTeamMemberRolePayload'; __typename?: 'UpdateTeamMemberRolePayload';
ok: Scalars['Boolean']; ok: Scalars['Boolean'];
member: Member;
};
export type SetTeamOwner = {
teamID: Scalars['UUID']; teamID: Scalars['UUID'];
userID: Scalars['UUID']; member: Member;
};
export type SetTeamOwnerPayload = {
__typename?: 'SetTeamOwnerPayload';
ok: Scalars['Boolean'];
prevOwner: Member;
newOwner: Member;
}; };
export type UpdateUserPassword = { export type UpdateUserPassword = {
@ -1066,7 +1068,7 @@ export type DeleteTaskGroupMutation = (
); );
export type FindProjectQueryVariables = { export type FindProjectQueryVariables = {
projectId: Scalars['String']; projectID: Scalars['UUID'];
}; };
@ -1075,7 +1077,10 @@ export type FindProjectQuery = (
& { findProject: ( & { findProject: (
{ __typename?: 'Project' } { __typename?: 'Project' }
& Pick<Project, 'name'> & Pick<Project, 'name'>
& { members: Array<( & { team: (
{ __typename?: 'Team' }
& Pick<Team, 'id'>
), members: Array<(
{ __typename?: 'Member' } { __typename?: 'Member' }
& Pick<Member, 'id' | 'fullName' | 'username'> & Pick<Member, 'id' | 'fullName' | 'username'>
& { role: ( & { role: (
@ -1242,12 +1247,21 @@ export type MeQueryVariables = {};
export type MeQuery = ( export type MeQuery = (
{ __typename?: 'Query' } { __typename?: 'Query' }
& { me: ( & { me: (
{ __typename?: 'MePayload' }
& { user: (
{ __typename?: 'UserAccount' } { __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'fullName'> & Pick<UserAccount, 'id' | 'fullName'>
& { profileIcon: ( & { profileIcon: (
{ __typename?: 'ProfileIcon' } { __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'> & Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
) } ) }
), teamRoles: Array<(
{ __typename?: 'TeamRole' }
& Pick<TeamRole, 'teamID' | 'roleCode'>
)>, projectRoles: Array<(
{ __typename?: 'ProjectRole' }
& Pick<ProjectRole, 'projectID' | 'roleCode'>
)> }
) } ) }
); );
@ -1311,35 +1325,6 @@ export type DeleteProjectMemberMutation = (
) } ) }
); );
export type SetProjectOwnerMutationVariables = {
projectID: Scalars['UUID'];
ownerID: Scalars['UUID'];
};
export type SetProjectOwnerMutation = (
{ __typename?: 'Mutation' }
& { setProjectOwner: (
{ __typename?: 'SetProjectOwnerPayload' }
& Pick<SetProjectOwnerPayload, 'ok'>
& { newOwner: (
{ __typename?: 'Member' }
& Pick<Member, 'id'>
& { role: (
{ __typename?: 'Role' }
& Pick<Role, 'code' | 'name'>
) }
), prevOwner: (
{ __typename?: 'Member' }
& Pick<Member, 'id'>
& { role: (
{ __typename?: 'Role' }
& Pick<Role, 'code' | 'name'>
) }
) }
) }
);
export type UpdateProjectMemberRoleMutationVariables = { export type UpdateProjectMemberRoleMutationVariables = {
projectID: Scalars['UUID']; projectID: Scalars['UUID'];
userID: Scalars['UUID']; userID: Scalars['UUID'];
@ -1706,6 +1691,29 @@ export type GetTeamQuery = (
)> } )> }
); );
export type UpdateTeamMemberRoleMutationVariables = {
teamID: Scalars['UUID'];
userID: Scalars['UUID'];
roleCode: RoleCode;
};
export type UpdateTeamMemberRoleMutation = (
{ __typename?: 'Mutation' }
& { updateTeamMemberRole: (
{ __typename?: 'UpdateTeamMemberRolePayload' }
& Pick<UpdateTeamMemberRolePayload, 'teamID'>
& { member: (
{ __typename?: 'Member' }
& Pick<Member, 'id'>
& { role: (
{ __typename?: 'Role' }
& Pick<Role, 'code' | 'name'>
) }
) }
) }
);
export type ToggleTaskLabelMutationVariables = { export type ToggleTaskLabelMutationVariables = {
taskID: Scalars['UUID']; taskID: Scalars['UUID'];
projectLabelID: Scalars['UUID']; projectLabelID: Scalars['UUID'];
@ -2325,9 +2333,12 @@ export type DeleteTaskGroupMutationHookResult = ReturnType<typeof useDeleteTaskG
export type DeleteTaskGroupMutationResult = ApolloReactCommon.MutationResult<DeleteTaskGroupMutation>; export type DeleteTaskGroupMutationResult = ApolloReactCommon.MutationResult<DeleteTaskGroupMutation>;
export type DeleteTaskGroupMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteTaskGroupMutation, DeleteTaskGroupMutationVariables>; export type DeleteTaskGroupMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteTaskGroupMutation, DeleteTaskGroupMutationVariables>;
export const FindProjectDocument = gql` export const FindProjectDocument = gql`
query findProject($projectId: String!) { query findProject($projectID: UUID!) {
findProject(input: {projectId: $projectId}) { findProject(input: {projectID: $projectID}) {
name name
team {
id
}
members { members {
id id
fullName fullName
@ -2418,7 +2429,7 @@ export const FindProjectDocument = gql`
* @example * @example
* const { data, loading, error } = useFindProjectQuery({ * const { data, loading, error } = useFindProjectQuery({
* variables: { * variables: {
* projectId: // value for 'projectId' * projectID: // value for 'projectID'
* }, * },
* }); * });
*/ */
@ -2563,6 +2574,7 @@ export type GetProjectsQueryResult = ApolloReactCommon.QueryResult<GetProjectsQu
export const MeDocument = gql` export const MeDocument = gql`
query me { query me {
me { me {
user {
id id
fullName fullName
profileIcon { profileIcon {
@ -2571,6 +2583,15 @@ export const MeDocument = gql`
url url
} }
} }
teamRoles {
teamID
roleCode
}
projectRoles {
projectID
roleCode
}
}
} }
`; `;
@ -2717,53 +2738,6 @@ export function useDeleteProjectMemberMutation(baseOptions?: ApolloReactHooks.Mu
export type DeleteProjectMemberMutationHookResult = ReturnType<typeof useDeleteProjectMemberMutation>; export type DeleteProjectMemberMutationHookResult = ReturnType<typeof useDeleteProjectMemberMutation>;
export type DeleteProjectMemberMutationResult = ApolloReactCommon.MutationResult<DeleteProjectMemberMutation>; export type DeleteProjectMemberMutationResult = ApolloReactCommon.MutationResult<DeleteProjectMemberMutation>;
export type DeleteProjectMemberMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteProjectMemberMutation, DeleteProjectMemberMutationVariables>; export type DeleteProjectMemberMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteProjectMemberMutation, DeleteProjectMemberMutationVariables>;
export const SetProjectOwnerDocument = gql`
mutation setProjectOwner($projectID: UUID!, $ownerID: UUID!) {
setProjectOwner(input: {projectID: $projectID, ownerID: $ownerID}) {
ok
newOwner {
id
role {
code
name
}
}
prevOwner {
id
role {
code
name
}
}
}
}
`;
export type SetProjectOwnerMutationFn = ApolloReactCommon.MutationFunction<SetProjectOwnerMutation, SetProjectOwnerMutationVariables>;
/**
* __useSetProjectOwnerMutation__
*
* To run a mutation, you first call `useSetProjectOwnerMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSetProjectOwnerMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [setProjectOwnerMutation, { data, loading, error }] = useSetProjectOwnerMutation({
* variables: {
* projectID: // value for 'projectID'
* ownerID: // value for 'ownerID'
* },
* });
*/
export function useSetProjectOwnerMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<SetProjectOwnerMutation, SetProjectOwnerMutationVariables>) {
return ApolloReactHooks.useMutation<SetProjectOwnerMutation, SetProjectOwnerMutationVariables>(SetProjectOwnerDocument, baseOptions);
}
export type SetProjectOwnerMutationHookResult = ReturnType<typeof useSetProjectOwnerMutation>;
export type SetProjectOwnerMutationResult = ApolloReactCommon.MutationResult<SetProjectOwnerMutation>;
export type SetProjectOwnerMutationOptions = ApolloReactCommon.BaseMutationOptions<SetProjectOwnerMutation, SetProjectOwnerMutationVariables>;
export const UpdateProjectMemberRoleDocument = gql` export const UpdateProjectMemberRoleDocument = gql`
mutation updateProjectMemberRole($projectID: UUID!, $userID: UUID!, $roleCode: RoleCode!) { mutation updateProjectMemberRole($projectID: UUID!, $userID: UUID!, $roleCode: RoleCode!) {
updateProjectMemberRole(input: {projectID: $projectID, userID: $userID, roleCode: $roleCode}) { updateProjectMemberRole(input: {projectID: $projectID, userID: $userID, roleCode: $roleCode}) {
@ -3510,6 +3484,47 @@ export function useGetTeamLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHook
export type GetTeamQueryHookResult = ReturnType<typeof useGetTeamQuery>; export type GetTeamQueryHookResult = ReturnType<typeof useGetTeamQuery>;
export type GetTeamLazyQueryHookResult = ReturnType<typeof useGetTeamLazyQuery>; export type GetTeamLazyQueryHookResult = ReturnType<typeof useGetTeamLazyQuery>;
export type GetTeamQueryResult = ApolloReactCommon.QueryResult<GetTeamQuery, GetTeamQueryVariables>; export type GetTeamQueryResult = ApolloReactCommon.QueryResult<GetTeamQuery, GetTeamQueryVariables>;
export const UpdateTeamMemberRoleDocument = gql`
mutation updateTeamMemberRole($teamID: UUID!, $userID: UUID!, $roleCode: RoleCode!) {
updateTeamMemberRole(input: {teamID: $teamID, userID: $userID, roleCode: $roleCode}) {
member {
id
role {
code
name
}
}
teamID
}
}
`;
export type UpdateTeamMemberRoleMutationFn = ApolloReactCommon.MutationFunction<UpdateTeamMemberRoleMutation, UpdateTeamMemberRoleMutationVariables>;
/**
* __useUpdateTeamMemberRoleMutation__
*
* To run a mutation, you first call `useUpdateTeamMemberRoleMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateTeamMemberRoleMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateTeamMemberRoleMutation, { data, loading, error }] = useUpdateTeamMemberRoleMutation({
* variables: {
* teamID: // value for 'teamID'
* userID: // value for 'userID'
* roleCode: // value for 'roleCode'
* },
* });
*/
export function useUpdateTeamMemberRoleMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<UpdateTeamMemberRoleMutation, UpdateTeamMemberRoleMutationVariables>) {
return ApolloReactHooks.useMutation<UpdateTeamMemberRoleMutation, UpdateTeamMemberRoleMutationVariables>(UpdateTeamMemberRoleDocument, baseOptions);
}
export type UpdateTeamMemberRoleMutationHookResult = ReturnType<typeof useUpdateTeamMemberRoleMutation>;
export type UpdateTeamMemberRoleMutationResult = ApolloReactCommon.MutationResult<UpdateTeamMemberRoleMutation>;
export type UpdateTeamMemberRoleMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateTeamMemberRoleMutation, UpdateTeamMemberRoleMutationVariables>;
export const ToggleTaskLabelDocument = gql` export const ToggleTaskLabelDocument = gql`
mutation toggleTaskLabel($taskID: UUID!, $projectLabelID: UUID!) { mutation toggleTaskLabel($taskID: UUID!, $projectLabelID: UUID!) {
toggleTaskLabel(input: {taskID: $taskID, projectLabelID: $projectLabelID}) { toggleTaskLabel(input: {taskID: $taskID, projectLabelID: $projectLabelID}) {

View File

@ -2,9 +2,12 @@ import gql from 'graphql-tag';
import TASK_FRAGMENT from './fragments/task'; import TASK_FRAGMENT from './fragments/task';
const FIND_PROJECT_QUERY = gql` const FIND_PROJECT_QUERY = gql`
query findProject($projectId: String!) { query findProject($projectID: UUID!) {
findProject(input: { projectId: $projectId }) { findProject(input: { projectID: $projectID }) {
name name
team {
id
}
members { members {
id id
fullName fullName

View File

@ -1,5 +1,6 @@
query me { query me {
me { me {
user {
id id
fullName fullName
profileIcon { profileIcon {
@ -8,4 +9,13 @@ query me {
url url
} }
} }
teamRoles {
teamID
roleCode
}
projectRoles {
projectID
roleCode
}
}
} }

View File

@ -1,25 +0,0 @@
import gql from 'graphql-tag';
export const SET_PROJECT_OWNER_MUTATION = gql`
mutation setProjectOwner($projectID: UUID!, $ownerID: UUID!) {
setProjectOwner(input: { projectID: $projectID, ownerID: $ownerID }) {
ok
newOwner {
id
role {
code
name
}
}
prevOwner {
id
role {
code
name
}
}
}
}
`;
export default SET_PROJECT_OWNER_MUTATION;

View File

@ -0,0 +1,18 @@
import gql from 'graphql-tag';
export const UPDATE_TEAM_MEMBER_ROLE_MUTATION = gql`
mutation updateTeamMemberRole($teamID: UUID!, $userID: UUID!, $roleCode: RoleCode!) {
updateTeamMemberRole(input: { teamID: $teamID, userID: $userID, roleCode: $roleCode }) {
member {
id
role {
code
name
}
}
teamID
}
}
`;
export default UPDATE_TEAM_MEMBER_ROLE_MUTATION;

View File

@ -0,0 +1,5 @@
import { PermissionObjectType, PermissionLevel } from 'App/context';
export default function userCan(level: PermissionLevel, objectType: PermissionObjectType) {
return false;
}

View File

@ -1,5 +1,6 @@
interface JWTToken { interface JWTToken {
userId: string; userId: string;
orgRole: string;
iat: string; iat: string;
exp: string; exp: string;
} }

1
go.mod
View File

@ -17,7 +17,6 @@ require (
github.com/google/uuid v1.1.1 github.com/google/uuid v1.1.1
github.com/jmoiron/sqlx v1.2.0 github.com/jmoiron/sqlx v1.2.0
github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e github.com/jordan-wright/email v0.0.0-20200602115436-fd8a7622303e
github.com/jordanknott/project-citadel v0.0.0-20200801000017-7ffd0ff6b895
github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f
github.com/lib/pq v1.3.0 github.com/lib/pq v1.3.0
github.com/magefile/mage v1.9.0 github.com/magefile/mage v1.9.0

View File

@ -16,9 +16,17 @@ const (
InstallOnly = "install_only" InstallOnly = "install_only"
) )
type Role string
const (
RoleAdmin Role = "admin"
RoleMember Role = "member"
)
type AccessTokenClaims struct { type AccessTokenClaims struct {
UserID string `json:"userId"` UserID string `json:"userId"`
Restricted RestrictedMode `json:"restricted"` Restricted RestrictedMode `json:"restricted"`
OrgRole Role `json:"orgRole"`
jwt.StandardClaims jwt.StandardClaims
} }
@ -39,11 +47,16 @@ func (r *ErrMalformedToken) Error() string {
return "token is malformed" return "token is malformed"
} }
func NewAccessToken(userID string, restrictedMode RestrictedMode) (string, error) { func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string) (string, error) {
role := RoleMember
if orgRole == "admin" {
role = RoleAdmin
}
accessExpirationTime := time.Now().Add(5 * time.Second) accessExpirationTime := time.Now().Add(5 * time.Second)
accessClaims := &AccessTokenClaims{ accessClaims := &AccessTokenClaims{
UserID: userID, UserID: userID,
Restricted: restrictedMode, Restricted: restrictedMode,
OrgRole: role,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()}, StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
} }
@ -60,6 +73,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
accessClaims := &AccessTokenClaims{ accessClaims := &AccessTokenClaims{
UserID: userID, UserID: userID,
Restricted: Unrestricted, Restricted: Unrestricted,
OrgRole: RoleMember,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()}, StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
} }

View File

@ -35,6 +35,6 @@ var rootCmd = &cobra.Command{
func Execute() { func Execute() {
rootCmd.SetVersionTemplate(versionTemplate) rootCmd.SetVersionTemplate(versionTemplate)
rootCmd.AddCommand(newWebCmd(), newMigrateCmd()) rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newTokenCmd())
rootCmd.Execute() rootCmd.Execute()
} }

View File

@ -2,6 +2,8 @@ package commands
import ( import (
"fmt" "fmt"
"net/http"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
@ -10,7 +12,6 @@ import (
"github.com/golang-migrate/migrate/v4/source/httpfs" "github.com/golang-migrate/migrate/v4/source/httpfs"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/jordanknott/taskcafe/internal/config" "github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/migrations"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -27,6 +28,12 @@ func (l *MigrateLog) Verbose() bool {
return l.verbose return l.verbose
} }
var migration http.FileSystem
func init() {
migration = http.Dir("./migrations")
}
func newMigrateCmd() *cobra.Command { func newMigrateCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "migrate", Use: "migrate",
@ -53,7 +60,7 @@ func newMigrateCmd() *cobra.Command {
return err return err
} }
src, err := httpfs.New(migrations.Migrations, "./") src, err := httpfs.New(migration, "./")
if err != nil { if err != nil {
return err return err
} }

View File

@ -0,0 +1,13 @@
// +build prod
package commands
import (
"fmt"
"github.com/jordanknott/taskcafe/internal/migrations"
)
func init() {
migration = migrations.Migrations
}

View File

@ -0,0 +1,27 @@
package commands
import (
"fmt"
"time"
"github.com/jordanknott/taskcafe/internal/auth"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
func newTokenCmd() *cobra.Command {
return &cobra.Command{
Use: "token",
Short: "Create a long lived JWT token for dev purposes",
Long: "Create a long lived JWT token for dev purposes",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
token, err := auth.NewAccessTokenCustomExpiration(args[0], time.Hour*24)
if err != nil {
log.WithError(err).Error("issue while creating access token")
return
}
fmt.Println(token)
},
}
}

View File

@ -27,7 +27,6 @@ type Project struct {
TeamID uuid.UUID `json:"team_id"` TeamID uuid.UUID `json:"team_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Name string `json:"name"` Name string `json:"name"`
Owner uuid.UUID `json:"owner"`
} }
type ProjectLabel struct { type ProjectLabel struct {
@ -120,7 +119,6 @@ type Team struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Name string `json:"name"` Name string `json:"name"`
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
Owner uuid.UUID `json:"owner"`
} }
type TeamMember struct { type TeamMember struct {

View File

@ -11,30 +11,23 @@ import (
) )
const createProject = `-- name: CreateProject :one const createProject = `-- name: CreateProject :one
INSERT INTO project(owner, team_id, created_at, name) VALUES ($1, $2, $3, $4) RETURNING project_id, team_id, created_at, name, owner INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING project_id, team_id, created_at, name
` `
type CreateProjectParams struct { type CreateProjectParams struct {
Owner uuid.UUID `json:"owner"`
TeamID uuid.UUID `json:"team_id"` TeamID uuid.UUID `json:"team_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Name string `json:"name"` Name string `json:"name"`
} }
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) { func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
row := q.db.QueryRowContext(ctx, createProject, row := q.db.QueryRowContext(ctx, createProject, arg.TeamID, arg.CreatedAt, arg.Name)
arg.Owner,
arg.TeamID,
arg.CreatedAt,
arg.Name,
)
var i Project var i Project
err := row.Scan( err := row.Scan(
&i.ProjectID, &i.ProjectID,
&i.TeamID, &i.TeamID,
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.Owner,
) )
return i, err return i, err
} }
@ -93,7 +86,7 @@ func (q *Queries) DeleteProjectMember(ctx context.Context, arg DeleteProjectMemb
} }
const getAllProjects = `-- name: GetAllProjects :many const getAllProjects = `-- name: GetAllProjects :many
SELECT project_id, team_id, created_at, name, owner FROM project SELECT project_id, team_id, created_at, name FROM project
` `
func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) { func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) {
@ -110,7 +103,6 @@ func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) {
&i.TeamID, &i.TeamID,
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.Owner,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -126,7 +118,7 @@ func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) {
} }
const getAllProjectsForTeam = `-- name: GetAllProjectsForTeam :many const getAllProjectsForTeam = `-- name: GetAllProjectsForTeam :many
SELECT project_id, team_id, created_at, name, owner FROM project WHERE team_id = $1 SELECT project_id, team_id, created_at, name FROM project WHERE team_id = $1
` `
func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) { func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) {
@ -143,7 +135,39 @@ func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) (
&i.TeamID, &i.TeamID,
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.Owner, ); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAllVisibleProjectsForUserID = `-- name: GetAllVisibleProjectsForUserID :many
SELECT project.project_id, project.team_id, project.created_at, project.name FROM project LEFT JOIN
project_member ON project_member.project_id = project.project_id WHERE project_member.user_id = $1
`
func (q *Queries) GetAllVisibleProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getAllVisibleProjectsForUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Project
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ProjectID,
&i.TeamID,
&i.CreatedAt,
&i.Name,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -185,73 +209,8 @@ func (q *Queries) GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.
return items, nil return items, nil
} }
const getOwnedProjectsForUserID = `-- name: GetOwnedProjectsForUserID :many
SELECT project_id, team_id, created_at, name, owner FROM project WHERE owner = $1
`
func (q *Queries) GetOwnedProjectsForUserID(ctx context.Context, owner uuid.UUID) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getOwnedProjectsForUserID, owner)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Project
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ProjectID,
&i.TeamID,
&i.CreatedAt,
&i.Name,
&i.Owner,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getOwnedTeamProjectsForUserID = `-- name: GetOwnedTeamProjectsForUserID :many
SELECT project_id FROM project WHERE owner = $1 AND team_id = $2
`
type GetOwnedTeamProjectsForUserIDParams struct {
Owner uuid.UUID `json:"owner"`
TeamID uuid.UUID `json:"team_id"`
}
func (q *Queries) GetOwnedTeamProjectsForUserID(ctx context.Context, arg GetOwnedTeamProjectsForUserIDParams) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, getOwnedTeamProjectsForUserID, arg.Owner, arg.TeamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []uuid.UUID
for rows.Next() {
var project_id uuid.UUID
if err := rows.Scan(&project_id); err != nil {
return nil, err
}
items = append(items, project_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getProjectByID = `-- name: GetProjectByID :one const getProjectByID = `-- name: GetProjectByID :one
SELECT project_id, team_id, created_at, name, owner FROM project WHERE project_id = $1 SELECT project_id, team_id, created_at, name FROM project WHERE project_id = $1
` `
func (q *Queries) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error) { func (q *Queries) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error) {
@ -262,7 +221,6 @@ func (q *Queries) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Proj
&i.TeamID, &i.TeamID,
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.Owner,
) )
return i, err return i, err
} }
@ -300,6 +258,38 @@ func (q *Queries) GetProjectMembersForProjectID(ctx context.Context, projectID u
return items, nil return items, nil
} }
const getProjectRolesForUserID = `-- name: GetProjectRolesForUserID :many
SELECT project_id, role_code FROM project_member WHERE user_id = $1
`
type GetProjectRolesForUserIDRow struct {
ProjectID uuid.UUID `json:"project_id"`
RoleCode string `json:"role_code"`
}
func (q *Queries) GetProjectRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetProjectRolesForUserIDRow, error) {
rows, err := q.db.QueryContext(ctx, getProjectRolesForUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetProjectRolesForUserIDRow
for rows.Next() {
var i GetProjectRolesForUserIDRow
if err := rows.Scan(&i.ProjectID, &i.RoleCode); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRoleForProjectMemberByUserID = `-- name: GetRoleForProjectMemberByUserID :one const getRoleForProjectMemberByUserID = `-- name: GetRoleForProjectMemberByUserID :one
SELECT code, role.name FROM project_member INNER JOIN role ON role.code = project_member.role_code SELECT code, role.name FROM project_member INNER JOIN role ON role.code = project_member.role_code
WHERE user_id = $1 AND project_id = $2 WHERE user_id = $1 AND project_id = $2
@ -317,25 +307,29 @@ func (q *Queries) GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRo
return i, err return i, err
} }
const setProjectOwner = `-- name: SetProjectOwner :one const getUserRolesForProject = `-- name: GetUserRolesForProject :one
UPDATE project SET owner = $2 WHERE project_id = $1 RETURNING project_id, team_id, created_at, name, owner SELECT p.team_id, COALESCE(tm.role_code, '') AS team_role, COALESCE(pm.role_code, '') AS project_role
FROM project AS p
LEFT JOIN project_member AS pm ON pm.project_id = p.project_id AND pm.user_id = $1
LEFT JOIN team_member AS tm ON tm.team_id = p.team_id AND tm.user_id = $1
WHERE p.project_id = $2
` `
type SetProjectOwnerParams struct { type GetUserRolesForProjectParams struct {
UserID uuid.UUID `json:"user_id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
Owner uuid.UUID `json:"owner"`
} }
func (q *Queries) SetProjectOwner(ctx context.Context, arg SetProjectOwnerParams) (Project, error) { type GetUserRolesForProjectRow struct {
row := q.db.QueryRowContext(ctx, setProjectOwner, arg.ProjectID, arg.Owner) TeamID uuid.UUID `json:"team_id"`
var i Project TeamRole string `json:"team_role"`
err := row.Scan( ProjectRole string `json:"project_role"`
&i.ProjectID, }
&i.TeamID,
&i.CreatedAt, func (q *Queries) GetUserRolesForProject(ctx context.Context, arg GetUserRolesForProjectParams) (GetUserRolesForProjectRow, error) {
&i.Name, row := q.db.QueryRowContext(ctx, getUserRolesForProject, arg.UserID, arg.ProjectID)
&i.Owner, var i GetUserRolesForProjectRow
) err := row.Scan(&i.TeamID, &i.TeamRole, &i.ProjectRole)
return i, err return i, err
} }
@ -364,7 +358,7 @@ func (q *Queries) UpdateProjectMemberRole(ctx context.Context, arg UpdateProject
} }
const updateProjectNameByID = `-- name: UpdateProjectNameByID :one const updateProjectNameByID = `-- name: UpdateProjectNameByID :one
UPDATE project SET name = $2 WHERE project_id = $1 RETURNING project_id, team_id, created_at, name, owner UPDATE project SET name = $2 WHERE project_id = $1 RETURNING project_id, team_id, created_at, name
` `
type UpdateProjectNameByIDParams struct { type UpdateProjectNameByIDParams struct {
@ -380,39 +374,6 @@ func (q *Queries) UpdateProjectNameByID(ctx context.Context, arg UpdateProjectNa
&i.TeamID, &i.TeamID,
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.Owner,
) )
return i, err return i, err
} }
const updateProjectOwnerByOwnerID = `-- name: UpdateProjectOwnerByOwnerID :many
UPDATE project SET owner = $2 WHERE owner = $1 RETURNING project_id
`
type UpdateProjectOwnerByOwnerIDParams struct {
Owner uuid.UUID `json:"owner"`
Owner_2 uuid.UUID `json:"owner_2"`
}
func (q *Queries) UpdateProjectOwnerByOwnerID(ctx context.Context, arg UpdateProjectOwnerByOwnerIDParams) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, updateProjectOwnerByOwnerID, arg.Owner, arg.Owner_2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []uuid.UUID
for rows.Next() {
var project_id uuid.UUID
if err := rows.Scan(&project_id); err != nil {
return nil, err
}
items = append(items, project_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -49,19 +49,18 @@ type Querier interface {
GetAllTasks(ctx context.Context) ([]Task, error) GetAllTasks(ctx context.Context) ([]Task, error)
GetAllTeams(ctx context.Context) ([]Team, error) GetAllTeams(ctx context.Context) ([]Team, error)
GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
GetAllVisibleProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error)
GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error) GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error)
GetLabelColorByID(ctx context.Context, labelColorID uuid.UUID) (LabelColor, error) GetLabelColorByID(ctx context.Context, labelColorID uuid.UUID) (LabelColor, error)
GetLabelColors(ctx context.Context) ([]LabelColor, error) GetLabelColors(ctx context.Context) ([]LabelColor, error)
GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
GetMemberTeamIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) GetMemberTeamIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
GetOwnedProjectsForUserID(ctx context.Context, owner uuid.UUID) ([]Project, error)
GetOwnedTeamProjectsForUserID(ctx context.Context, arg GetOwnedTeamProjectsForUserIDParams) ([]uuid.UUID, error)
GetOwnedTeamsForUserID(ctx context.Context, owner uuid.UUID) ([]Team, error)
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uuid.UUID, error) GetProjectIDForTask(ctx context.Context, taskID uuid.UUID) (uuid.UUID, error)
GetProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) (ProjectLabel, error) GetProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) (ProjectLabel, error)
GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error) GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error)
GetProjectMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]ProjectMember, error) GetProjectMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]ProjectMember, error)
GetProjectRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetProjectRolesForUserIDRow, error)
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error) GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error) GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error) GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
@ -81,21 +80,22 @@ type Querier interface {
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error) GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
GetTeamMemberByID(ctx context.Context, arg GetTeamMemberByIDParams) (TeamMember, error) GetTeamMemberByID(ctx context.Context, arg GetTeamMemberByIDParams) (TeamMember, error)
GetTeamMembersForTeamID(ctx context.Context, teamID uuid.UUID) ([]TeamMember, error) GetTeamMembersForTeamID(ctx context.Context, teamID uuid.UUID) ([]TeamMember, error)
GetTeamRoleForUserID(ctx context.Context, arg GetTeamRoleForUserIDParams) (GetTeamRoleForUserIDRow, error)
GetTeamRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetTeamRolesForUserIDRow, error)
GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error) GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error)
GetTeamsForUserIDWhereAdmin(ctx context.Context, userID uuid.UUID) ([]Team, error)
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)
SetProjectOwner(ctx context.Context, arg SetProjectOwnerParams) (Project, error) GetUserRolesForProject(ctx context.Context, arg GetUserRolesForProjectParams) (GetUserRolesForProjectRow, error)
SetTaskChecklistItemComplete(ctx context.Context, arg SetTaskChecklistItemCompleteParams) (TaskChecklistItem, error) SetTaskChecklistItemComplete(ctx context.Context, arg SetTaskChecklistItemCompleteParams) (TaskChecklistItem, error)
SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams) (Task, error) SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams) (Task, error)
SetTaskGroupName(ctx context.Context, arg SetTaskGroupNameParams) (TaskGroup, error) SetTaskGroupName(ctx context.Context, arg SetTaskGroupNameParams) (TaskGroup, error)
SetTeamOwner(ctx context.Context, arg SetTeamOwnerParams) (Team, error)
SetUserPassword(ctx context.Context, arg SetUserPasswordParams) (UserAccount, error) SetUserPassword(ctx context.Context, arg SetUserPasswordParams) (UserAccount, error)
UpdateProjectLabel(ctx context.Context, arg UpdateProjectLabelParams) (ProjectLabel, error) UpdateProjectLabel(ctx context.Context, arg UpdateProjectLabelParams) (ProjectLabel, error)
UpdateProjectLabelColor(ctx context.Context, arg UpdateProjectLabelColorParams) (ProjectLabel, error) UpdateProjectLabelColor(ctx context.Context, arg UpdateProjectLabelColorParams) (ProjectLabel, error)
UpdateProjectLabelName(ctx context.Context, arg UpdateProjectLabelNameParams) (ProjectLabel, error) UpdateProjectLabelName(ctx context.Context, arg UpdateProjectLabelNameParams) (ProjectLabel, error)
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (ProjectMember, error) UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (ProjectMember, error)
UpdateProjectNameByID(ctx context.Context, arg UpdateProjectNameByIDParams) (Project, error) UpdateProjectNameByID(ctx context.Context, arg UpdateProjectNameByIDParams) (Project, error)
UpdateProjectOwnerByOwnerID(ctx context.Context, arg UpdateProjectOwnerByOwnerIDParams) ([]uuid.UUID, error)
UpdateTaskChecklistItemLocation(ctx context.Context, arg UpdateTaskChecklistItemLocationParams) (TaskChecklistItem, error) UpdateTaskChecklistItemLocation(ctx context.Context, arg UpdateTaskChecklistItemLocationParams) (TaskChecklistItem, error)
UpdateTaskChecklistItemName(ctx context.Context, arg UpdateTaskChecklistItemNameParams) (TaskChecklistItem, error) UpdateTaskChecklistItemName(ctx context.Context, arg UpdateTaskChecklistItemNameParams) (TaskChecklistItem, error)
UpdateTaskChecklistName(ctx context.Context, arg UpdateTaskChecklistNameParams) (TaskChecklist, error) UpdateTaskChecklistName(ctx context.Context, arg UpdateTaskChecklistNameParams) (TaskChecklist, error)
@ -106,7 +106,6 @@ type Querier interface {
UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error) UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error)
UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error)
UpdateTeamMemberRole(ctx context.Context, arg UpdateTeamMemberRoleParams) (TeamMember, error) UpdateTeamMemberRole(ctx context.Context, arg UpdateTeamMemberRoleParams) (TeamMember, error)
UpdateTeamOwnerByOwnerID(ctx context.Context, arg UpdateTeamOwnerByOwnerIDParams) ([]uuid.UUID, error)
UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error) UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error)
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UserAccount, error) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UserAccount, error)
} }

View File

@ -8,10 +8,7 @@ SELECT * FROM project WHERE team_id = $1;
SELECT * FROM project WHERE project_id = $1; SELECT * FROM project WHERE project_id = $1;
-- name: CreateProject :one -- name: CreateProject :one
INSERT INTO project(owner, team_id, created_at, name) VALUES ($1, $2, $3, $4) RETURNING *; INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING *;
-- name: SetProjectOwner :one
UPDATE project SET owner = $2 WHERE project_id = $1 RETURNING *;
-- name: UpdateProjectNameByID :one -- name: UpdateProjectNameByID :one
UPDATE project SET name = $2 WHERE project_id = $1 RETURNING *; UPDATE project SET name = $2 WHERE project_id = $1 RETURNING *;
@ -37,14 +34,19 @@ DELETE FROM project_member WHERE user_id = $1 AND project_id = $2;
UPDATE project_member SET role_code = $3 WHERE project_id = $1 AND user_id = $2 UPDATE project_member SET role_code = $3 WHERE project_id = $1 AND user_id = $2
RETURNING *; RETURNING *;
-- name: GetOwnedTeamProjectsForUserID :many -- name: GetProjectRolesForUserID :many
SELECT project_id FROM project WHERE owner = $1 AND team_id = $2; SELECT project_id, role_code FROM project_member WHERE user_id = $1;
-- name: GetOwnedProjectsForUserID :many
SELECT * FROM project WHERE owner = $1;
-- name: GetMemberProjectIDsForUserID :many -- name: GetMemberProjectIDsForUserID :many
SELECT project_id FROM project_member WHERE user_id = $1; SELECT project_id FROM project_member WHERE user_id = $1;
-- name: UpdateProjectOwnerByOwnerID :many -- name: GetAllVisibleProjectsForUserID :many
UPDATE project SET owner = $2 WHERE owner = $1 RETURNING project_id; SELECT project.* FROM project LEFT JOIN
project_member ON project_member.project_id = project.project_id WHERE project_member.user_id = $1;
-- name: GetUserRolesForProject :one
SELECT p.team_id, COALESCE(tm.role_code, '') AS team_role, COALESCE(pm.role_code, '') AS project_role
FROM project AS p
LEFT JOIN project_member AS pm ON pm.project_id = p.project_id AND pm.user_id = $1
LEFT JOIN team_member AS tm ON tm.team_id = p.team_id AND tm.user_id = $1
WHERE p.project_id = $2;

View File

@ -5,7 +5,7 @@ SELECT * FROM team;
SELECT * FROM team WHERE team_id = $1; SELECT * FROM team WHERE team_id = $1;
-- name: CreateTeam :one -- name: CreateTeam :one
INSERT INTO team (organization_id, created_at, name, owner) VALUES ($1, $2, $3, $4) RETURNING *; INSERT INTO team (organization_id, created_at, name) VALUES ($1, $2, $3) RETURNING *;
-- name: DeleteTeamByID :exec -- name: DeleteTeamByID :exec
DELETE FROM team WHERE team_id = $1; DELETE FROM team WHERE team_id = $1;
@ -13,14 +13,15 @@ DELETE FROM team WHERE team_id = $1;
-- name: GetTeamsForOrganization :many -- name: GetTeamsForOrganization :many
SELECT * FROM team WHERE organization_id = $1; SELECT * FROM team WHERE organization_id = $1;
-- name: SetTeamOwner :one
UPDATE team SET owner = $2 WHERE team_id = $1 RETURNING *;
-- name: GetOwnedTeamsForUserID :many
SELECT * FROM team WHERE owner = $1;
-- name: GetMemberTeamIDsForUserID :many -- name: GetMemberTeamIDsForUserID :many
SELECT team_id FROM team_member WHERE user_id = $1; SELECT team_id FROM team_member WHERE user_id = $1;
-- name: UpdateTeamOwnerByOwnerID :many -- name: GetTeamRoleForUserID :one
UPDATE team SET owner = $2 WHERE owner = $1 RETURNING team_id; SELECT team_id, role_code FROM team_member WHERE user_id = $1 AND team_id = $2;
-- name: GetTeamRolesForUserID :many
SELECT team_id, role_code FROM team_member WHERE user_id = $1;
-- name: GetTeamsForUserIDWhereAdmin :many
SELECT team.* FROM team_member INNER JOIN team
ON team.team_id = team_member.team_id WHERE (role_code = 'admin' OR role_code = 'member') AND user_id = $1;

View File

@ -11,30 +11,23 @@ import (
) )
const createTeam = `-- name: CreateTeam :one const createTeam = `-- name: CreateTeam :one
INSERT INTO team (organization_id, created_at, name, owner) VALUES ($1, $2, $3, $4) RETURNING team_id, created_at, name, organization_id, owner INSERT INTO team (organization_id, created_at, name) VALUES ($1, $2, $3) RETURNING team_id, created_at, name, organization_id
` `
type CreateTeamParams struct { type CreateTeamParams struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Name string `json:"name"` Name string `json:"name"`
Owner uuid.UUID `json:"owner"`
} }
func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) { func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) {
row := q.db.QueryRowContext(ctx, createTeam, row := q.db.QueryRowContext(ctx, createTeam, arg.OrganizationID, arg.CreatedAt, arg.Name)
arg.OrganizationID,
arg.CreatedAt,
arg.Name,
arg.Owner,
)
var i Team var i Team
err := row.Scan( err := row.Scan(
&i.TeamID, &i.TeamID,
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.OrganizationID, &i.OrganizationID,
&i.Owner,
) )
return i, err return i, err
} }
@ -49,7 +42,7 @@ func (q *Queries) DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error {
} }
const getAllTeams = `-- name: GetAllTeams :many const getAllTeams = `-- name: GetAllTeams :many
SELECT team_id, created_at, name, organization_id, owner FROM team SELECT team_id, created_at, name, organization_id FROM team
` `
func (q *Queries) GetAllTeams(ctx context.Context) ([]Team, error) { func (q *Queries) GetAllTeams(ctx context.Context) ([]Team, error) {
@ -66,7 +59,6 @@ func (q *Queries) GetAllTeams(ctx context.Context) ([]Team, error) {
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.OrganizationID, &i.OrganizationID,
&i.Owner,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -108,26 +100,62 @@ func (q *Queries) GetMemberTeamIDsForUserID(ctx context.Context, userID uuid.UUI
return items, nil return items, nil
} }
const getOwnedTeamsForUserID = `-- name: GetOwnedTeamsForUserID :many const getTeamByID = `-- name: GetTeamByID :one
SELECT team_id, created_at, name, organization_id, owner FROM team WHERE owner = $1 SELECT team_id, created_at, name, organization_id FROM team WHERE team_id = $1
` `
func (q *Queries) GetOwnedTeamsForUserID(ctx context.Context, owner uuid.UUID) ([]Team, error) { func (q *Queries) GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error) {
rows, err := q.db.QueryContext(ctx, getOwnedTeamsForUserID, owner) row := q.db.QueryRowContext(ctx, getTeamByID, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Team
for rows.Next() {
var i Team var i Team
if err := rows.Scan( err := row.Scan(
&i.TeamID, &i.TeamID,
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.OrganizationID, &i.OrganizationID,
&i.Owner, )
); err != nil { return i, err
}
const getTeamRoleForUserID = `-- name: GetTeamRoleForUserID :one
SELECT team_id, role_code FROM team_member WHERE user_id = $1 AND team_id = $2
`
type GetTeamRoleForUserIDParams struct {
UserID uuid.UUID `json:"user_id"`
TeamID uuid.UUID `json:"team_id"`
}
type GetTeamRoleForUserIDRow struct {
TeamID uuid.UUID `json:"team_id"`
RoleCode string `json:"role_code"`
}
func (q *Queries) GetTeamRoleForUserID(ctx context.Context, arg GetTeamRoleForUserIDParams) (GetTeamRoleForUserIDRow, error) {
row := q.db.QueryRowContext(ctx, getTeamRoleForUserID, arg.UserID, arg.TeamID)
var i GetTeamRoleForUserIDRow
err := row.Scan(&i.TeamID, &i.RoleCode)
return i, err
}
const getTeamRolesForUserID = `-- name: GetTeamRolesForUserID :many
SELECT team_id, role_code FROM team_member WHERE user_id = $1
`
type GetTeamRolesForUserIDRow struct {
TeamID uuid.UUID `json:"team_id"`
RoleCode string `json:"role_code"`
}
func (q *Queries) GetTeamRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetTeamRolesForUserIDRow, error) {
rows, err := q.db.QueryContext(ctx, getTeamRolesForUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTeamRolesForUserIDRow
for rows.Next() {
var i GetTeamRolesForUserIDRow
if err := rows.Scan(&i.TeamID, &i.RoleCode); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@ -141,25 +169,8 @@ func (q *Queries) GetOwnedTeamsForUserID(ctx context.Context, owner uuid.UUID) (
return items, nil return items, nil
} }
const getTeamByID = `-- name: GetTeamByID :one
SELECT team_id, created_at, name, organization_id, owner FROM team WHERE team_id = $1
`
func (q *Queries) GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error) {
row := q.db.QueryRowContext(ctx, getTeamByID, teamID)
var i Team
err := row.Scan(
&i.TeamID,
&i.CreatedAt,
&i.Name,
&i.OrganizationID,
&i.Owner,
)
return i, err
}
const getTeamsForOrganization = `-- name: GetTeamsForOrganization :many const getTeamsForOrganization = `-- name: GetTeamsForOrganization :many
SELECT team_id, created_at, name, organization_id, owner FROM team WHERE organization_id = $1 SELECT team_id, created_at, name, organization_id FROM team WHERE organization_id = $1
` `
func (q *Queries) GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error) { func (q *Queries) GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error) {
@ -176,7 +187,6 @@ func (q *Queries) GetTeamsForOrganization(ctx context.Context, organizationID uu
&i.CreatedAt, &i.CreatedAt,
&i.Name, &i.Name,
&i.OrganizationID, &i.OrganizationID,
&i.Owner,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -191,50 +201,29 @@ func (q *Queries) GetTeamsForOrganization(ctx context.Context, organizationID uu
return items, nil return items, nil
} }
const setTeamOwner = `-- name: SetTeamOwner :one const getTeamsForUserIDWhereAdmin = `-- name: GetTeamsForUserIDWhereAdmin :many
UPDATE team SET owner = $2 WHERE team_id = $1 RETURNING team_id, created_at, name, organization_id, owner SELECT team.team_id, team.created_at, team.name, team.organization_id FROM team_member INNER JOIN team
ON team.team_id = team_member.team_id WHERE (role_code = 'admin' OR role_code = 'member') AND user_id = $1
` `
type SetTeamOwnerParams struct { func (q *Queries) GetTeamsForUserIDWhereAdmin(ctx context.Context, userID uuid.UUID) ([]Team, error) {
TeamID uuid.UUID `json:"team_id"` rows, err := q.db.QueryContext(ctx, getTeamsForUserIDWhereAdmin, userID)
Owner uuid.UUID `json:"owner"`
}
func (q *Queries) SetTeamOwner(ctx context.Context, arg SetTeamOwnerParams) (Team, error) {
row := q.db.QueryRowContext(ctx, setTeamOwner, arg.TeamID, arg.Owner)
var i Team
err := row.Scan(
&i.TeamID,
&i.CreatedAt,
&i.Name,
&i.OrganizationID,
&i.Owner,
)
return i, err
}
const updateTeamOwnerByOwnerID = `-- name: UpdateTeamOwnerByOwnerID :many
UPDATE team SET owner = $2 WHERE owner = $1 RETURNING team_id
`
type UpdateTeamOwnerByOwnerIDParams struct {
Owner uuid.UUID `json:"owner"`
Owner_2 uuid.UUID `json:"owner_2"`
}
func (q *Queries) UpdateTeamOwnerByOwnerID(ctx context.Context, arg UpdateTeamOwnerByOwnerIDParams) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, updateTeamOwnerByOwnerID, arg.Owner, arg.Owner_2)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []uuid.UUID var items []Team
for rows.Next() { for rows.Next() {
var team_id uuid.UUID var i Team
if err := rows.Scan(&team_id); err != nil { if err := rows.Scan(
&i.TeamID,
&i.CreatedAt,
&i.Name,
&i.OrganizationID,
); err != nil {
return nil, err return nil, err
} }
items = append(items, team_id) items = append(items, i)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err

File diff suppressed because it is too large Load Diff

View File

@ -2,10 +2,13 @@ package graph
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"os" "os"
"reflect"
"time" "time"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension" "github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/lru" "github.com/99designs/gqlgen/graphql/handler/lru"
@ -15,16 +18,71 @@ import (
"github.com/jordanknott/taskcafe/internal/auth" "github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/config" "github.com/jordanknott/taskcafe/internal/config"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror"
) )
// NewHandler returns a new graphql endpoint handler. // NewHandler returns a new graphql endpoint handler.
func NewHandler(config config.AppConfig, repo db.Repository) http.Handler { func NewHandler(config config.AppConfig, repo db.Repository) http.Handler {
srv := handler.New(NewExecutableSchema(Config{ c := Config{
Resolvers: &Resolver{ Resolvers: &Resolver{
Config: config, Config: config,
Repository: repo, Repository: repo,
}, },
})) }
c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) {
role, ok := GetUserRole(ctx)
if !ok {
return nil, errors.New("user ID is missing")
}
if role == "admin" {
return next(ctx)
} else if level == ActionLevelOrg {
return nil, errors.New("must be an org admin")
}
var subjectID uuid.UUID
in := graphql.GetResolverContext(ctx).Args["input"]
if typeArg == ObjectTypeProject || typeArg == ObjectTypeTeam {
val := reflect.ValueOf(in) // could be any underlying type
fieldName := "ProjectID"
if typeArg == ObjectTypeTeam {
fieldName = "TeamID"
}
subjectID, ok = val.FieldByName(fieldName).Interface().(uuid.UUID)
if !ok {
return nil, errors.New("error while casting subject uuid")
}
}
if level == ActionLevelProject {
roles, err := GetProjectRoles(ctx, repo, subjectID)
if err != nil {
return nil, err
}
if roles.TeamRole == "admin" || roles.ProjectRole == "admin" {
log.WithFields(log.Fields{"teamRole": roles.TeamRole, "projectRole": roles.ProjectRole}).Info("is team or project role")
return next(ctx)
}
return nil, errors.New("must be a team or project admin")
} else if level == ActionLevelTeam {
userID, ok := GetUserID(ctx)
if !ok {
return nil, errors.New("user id is missing")
}
role, err := repo.GetTeamRoleForUserID(ctx, db.GetTeamRoleForUserIDParams{UserID: userID, TeamID: subjectID})
if err != nil {
return nil, err
}
if role.RoleCode == "admin" {
return next(ctx)
}
return nil, errors.New("must be a team admin")
}
return nil, errors.New("invalid path")
}
srv := handler.New(NewExecutableSchema(c))
srv.AddTransport(transport.Websocket{ srv.AddTransport(transport.Websocket{
KeepAlivePingInterval: 10 * time.Second, KeepAlivePingInterval: 10 * time.Second,
}) })
@ -55,8 +113,80 @@ func GetUserID(ctx context.Context) (uuid.UUID, bool) {
userID, ok := ctx.Value("userID").(uuid.UUID) userID, ok := ctx.Value("userID").(uuid.UUID)
return userID, ok return userID, ok
} }
func GetUserRole(ctx context.Context) (auth.Role, bool) {
role, ok := ctx.Value("org_role").(auth.Role)
return role, ok
}
func GetUser(ctx context.Context) (uuid.UUID, auth.Role, bool) {
userID, userOK := GetUserID(ctx)
role, roleOK := GetUserRole(ctx)
return userID, role, userOK && roleOK
}
func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) { func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) {
restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode) restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode)
return restricted, ok return restricted, ok
} }
func GetProjectRoles(ctx context.Context, r db.Repository, projectID uuid.UUID) (db.GetUserRolesForProjectRow, error) {
userID, ok := GetUserID(ctx)
if !ok {
return db.GetUserRolesForProjectRow{}, errors.New("user ID is not found")
}
return r.GetUserRolesForProject(ctx, db.GetUserRolesForProjectParams{UserID: userID, ProjectID: projectID})
}
func ConvertToRoleCode(r string) RoleCode {
if r == RoleCodeAdmin.String() {
return RoleCodeAdmin
}
if r == RoleCodeMember.String() {
return RoleCodeMember
}
return RoleCodeObserver
}
func RequireTeamAdmin(ctx context.Context, r db.Repository, teamID uuid.UUID) error {
userID, role, ok := GetUser(ctx)
if !ok {
return errors.New("internal: user id is not set")
}
teamRole, err := r.GetTeamRoleForUserID(ctx, db.GetTeamRoleForUserIDParams{UserID: userID, TeamID: teamID})
isAdmin := role == auth.RoleAdmin
isTeamAdmin := err == nil && ConvertToRoleCode(teamRole.RoleCode) == RoleCodeAdmin
if !(isAdmin || isTeamAdmin) {
return &gqlerror.Error{
Message: "organization or team admin role required",
Extensions: map[string]interface{}{
"code": "2-400",
},
}
} else if err != nil {
return err
}
return nil
}
func RequireProjectOrTeamAdmin(ctx context.Context, r db.Repository, projectID uuid.UUID) error {
role, ok := GetUserRole(ctx)
if !ok {
return errors.New("user ID is not set")
}
if role == auth.RoleAdmin {
return nil
}
roles, err := GetProjectRoles(ctx, r, projectID)
if err != nil {
return err
}
if !(roles.ProjectRole == "admin" || roles.TeamRole == "admin") {
return &gqlerror.Error{
Message: "You must be a team or project admin",
Extensions: map[string]interface{}{
"code": "4-400",
},
}
}
return nil
}

View File

@ -8,15 +8,7 @@ import (
) )
func GetOwnedList(ctx context.Context, r db.Repository, user db.UserAccount) (*OwnedList, error) { func GetOwnedList(ctx context.Context, r db.Repository, user db.UserAccount) (*OwnedList, error) {
ownedTeams, err := r.GetOwnedTeamsForUserID(ctx, user.UserID) return &OwnedList{}, nil
if err != sql.ErrNoRows && err != nil {
return &OwnedList{}, err
}
ownedProjects, err := r.GetOwnedProjectsForUserID(ctx, user.UserID)
if err != sql.ErrNoRows && err != nil {
return &OwnedList{}, err
}
return &OwnedList{Teams: ownedTeams, Projects: ownedProjects}, nil
} }
func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*MemberList, error) { func GetMemberList(ctx context.Context, r db.Repository, user db.UserAccount) (*MemberList, error) {
projectMemberIDs, err := r.GetMemberProjectIDsForUserID(ctx, user.UserID) projectMemberIDs, err := r.GetMemberProjectIDsForUserID(ctx, user.UserID)

View File

@ -152,7 +152,7 @@ type DeleteUserAccountPayload struct {
} }
type FindProject struct { type FindProject struct {
ProjectID string `json:"projectId"` ProjectID uuid.UUID `json:"projectID"`
} }
type FindTask struct { type FindTask struct {
@ -171,6 +171,12 @@ type LogoutUser struct {
UserID string `json:"userID"` UserID string `json:"userID"`
} }
type MePayload struct {
User *db.UserAccount `json:"user"`
TeamRoles []TeamRole `json:"teamRoles"`
ProjectRoles []ProjectRole `json:"projectRoles"`
}
type Member struct { type Member struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
Role *db.Role `json:"role"` Role *db.Role `json:"role"`
@ -255,6 +261,11 @@ type ProfileIcon struct {
BgColor *string `json:"bgColor"` BgColor *string `json:"bgColor"`
} }
type ProjectRole struct {
ProjectID uuid.UUID `json:"projectID"`
RoleCode RoleCode `json:"roleCode"`
}
type ProjectsFilter struct { type ProjectsFilter struct {
TeamID *uuid.UUID `json:"teamID"` TeamID *uuid.UUID `json:"teamID"`
} }
@ -263,17 +274,6 @@ type RemoveTaskLabelInput struct {
TaskLabelID uuid.UUID `json:"taskLabelID"` TaskLabelID uuid.UUID `json:"taskLabelID"`
} }
type SetProjectOwner struct {
ProjectID uuid.UUID `json:"projectID"`
OwnerID uuid.UUID `json:"ownerID"`
}
type SetProjectOwnerPayload struct {
Ok bool `json:"ok"`
PrevOwner *Member `json:"prevOwner"`
NewOwner *Member `json:"newOwner"`
}
type SetTaskChecklistItemComplete struct { type SetTaskChecklistItemComplete struct {
TaskChecklistItemID uuid.UUID `json:"taskChecklistItemID"` TaskChecklistItemID uuid.UUID `json:"taskChecklistItemID"`
Complete bool `json:"complete"` Complete bool `json:"complete"`
@ -284,21 +284,15 @@ type SetTaskComplete struct {
Complete bool `json:"complete"` Complete bool `json:"complete"`
} }
type SetTeamOwner struct {
TeamID uuid.UUID `json:"teamID"`
UserID uuid.UUID `json:"userID"`
}
type SetTeamOwnerPayload struct {
Ok bool `json:"ok"`
PrevOwner *Member `json:"prevOwner"`
NewOwner *Member `json:"newOwner"`
}
type TaskBadges struct { type TaskBadges struct {
Checklist *ChecklistBadge `json:"checklist"` Checklist *ChecklistBadge `json:"checklist"`
} }
type TeamRole struct {
TeamID uuid.UUID `json:"teamID"`
RoleCode RoleCode `json:"roleCode"`
}
type ToggleTaskLabelInput struct { type ToggleTaskLabelInput struct {
TaskID uuid.UUID `json:"taskID"` TaskID uuid.UUID `json:"taskID"`
ProjectLabelID uuid.UUID `json:"projectLabelID"` ProjectLabelID uuid.UUID `json:"projectLabelID"`
@ -410,6 +404,7 @@ type UpdateTeamMemberRole struct {
type UpdateTeamMemberRolePayload struct { type UpdateTeamMemberRolePayload struct {
Ok bool `json:"ok"` Ok bool `json:"ok"`
TeamID uuid.UUID `json:"teamID"`
Member *Member `json:"member"` Member *Member `json:"member"`
} }
@ -432,6 +427,94 @@ type UpdateUserRolePayload struct {
User *db.UserAccount `json:"user"` User *db.UserAccount `json:"user"`
} }
type ActionLevel string
const (
ActionLevelOrg ActionLevel = "ORG"
ActionLevelTeam ActionLevel = "TEAM"
ActionLevelProject ActionLevel = "PROJECT"
)
var AllActionLevel = []ActionLevel{
ActionLevelOrg,
ActionLevelTeam,
ActionLevelProject,
}
func (e ActionLevel) IsValid() bool {
switch e {
case ActionLevelOrg, ActionLevelTeam, ActionLevelProject:
return true
}
return false
}
func (e ActionLevel) String() string {
return string(e)
}
func (e *ActionLevel) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ActionLevel(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ActionLevel", str)
}
return nil
}
func (e ActionLevel) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type ObjectType string
const (
ObjectTypeOrg ObjectType = "ORG"
ObjectTypeTeam ObjectType = "TEAM"
ObjectTypeProject ObjectType = "PROJECT"
ObjectTypeTask ObjectType = "TASK"
)
var AllObjectType = []ObjectType{
ObjectTypeOrg,
ObjectTypeTeam,
ObjectTypeProject,
ObjectTypeTask,
}
func (e ObjectType) IsValid() bool {
switch e {
case ObjectTypeOrg, ObjectTypeTeam, ObjectTypeProject, ObjectTypeTask:
return true
}
return false
}
func (e ObjectType) String() string {
return string(e)
}
func (e *ObjectType) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ObjectType(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ObjectType", str)
}
return nil
}
func (e ObjectType) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type RoleCode string type RoleCode string
const ( const (
@ -476,3 +559,44 @@ func (e *RoleCode) UnmarshalGQL(v interface{}) error {
func (e RoleCode) MarshalGQL(w io.Writer) { func (e RoleCode) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String())) fmt.Fprint(w, strconv.Quote(e.String()))
} }
type RoleLevel string
const (
RoleLevelAdmin RoleLevel = "ADMIN"
RoleLevelMember RoleLevel = "MEMBER"
)
var AllRoleLevel = []RoleLevel{
RoleLevelAdmin,
RoleLevelMember,
}
func (e RoleLevel) IsValid() bool {
switch e {
case RoleLevelAdmin, RoleLevelMember:
return true
}
return false
}
func (e RoleLevel) String() string {
return string(e)
}
func (e *RoleLevel) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = RoleLevel(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid RoleLevel", str)
}
return nil
}
func (e RoleLevel) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}

View File

@ -97,7 +97,6 @@ type Project {
createdAt: Time! createdAt: Time!
name: String! name: String!
team: Team! team: Team!
owner: Member!
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
members: [Member!]! members: [Member!]!
labels: [ProjectLabel!]! labels: [ProjectLabel!]!
@ -157,6 +156,26 @@ type TaskChecklist {
items: [TaskChecklistItem!]! items: [TaskChecklistItem!]!
} }
enum RoleLevel {
ADMIN
MEMBER
}
enum ActionLevel {
ORG
TEAM
PROJECT
}
enum ObjectType {
ORG
TEAM
PROJECT
TASK
}
directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!) on FIELD_DEFINITION
type Query { type Query {
organizations: [Organization!]! organizations: [Organization!]!
users: [UserAccount!]! users: [UserAccount!]!
@ -168,11 +187,27 @@ type Query {
teams: [Team!]! teams: [Team!]!
labelColors: [LabelColor!]! labelColors: [LabelColor!]!
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
me: UserAccount! me: MePayload!
} }
type Mutation type Mutation
type TeamRole {
teamID: UUID!
roleCode: RoleCode!
}
type ProjectRole {
projectID: UUID!
roleCode: RoleCode!
}
type MePayload {
user: UserAccount!
teamRoles: [TeamRole!]!
projectRoles: [ProjectRole!]!
}
input ProjectsFilter { input ProjectsFilter {
teamID: UUID teamID: UUID
} }
@ -182,7 +217,7 @@ input FindUser {
} }
input FindProject { input FindProject {
projectId: String! projectID: UUID!
} }
input FindTask { input FindTask {
@ -194,9 +229,11 @@ input FindTeam {
} }
extend type Mutation { extend type Mutation {
createProject(input: NewProject!): Project! createProject(input: NewProject!): Project! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
deleteProject(input: DeleteProject!): DeleteProjectPayload! deleteProject(input: DeleteProject!):
updateProjectName(input: UpdateProjectName): Project! DeleteProjectPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectName(input: UpdateProjectName):
Project! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }
input NewProject { input NewProject {
@ -221,11 +258,16 @@ type DeleteProjectPayload {
extend type Mutation { extend type Mutation {
createProjectLabel(input: NewProjectLabel!): ProjectLabel! createProjectLabel(input: NewProjectLabel!):
deleteProjectLabel(input: DeleteProjectLabel!): ProjectLabel! ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectLabel(input: UpdateProjectLabel!): ProjectLabel! deleteProjectLabel(input: DeleteProjectLabel!):
updateProjectLabelName(input: UpdateProjectLabelName!): ProjectLabel! ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectLabelColor(input: UpdateProjectLabelColor!): ProjectLabel! updateProjectLabel(input: UpdateProjectLabel!):
ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectLabelName(input: UpdateProjectLabelName!):
ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectLabelColor(input: UpdateProjectLabelColor!):
ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }
input NewProjectLabel { input NewProjectLabel {
@ -255,10 +297,12 @@ input UpdateProjectLabelColor {
} }
extend type Mutation { extend type Mutation {
createProjectMember(input: CreateProjectMember!): CreateProjectMemberPayload! createProjectMember(input: CreateProjectMember!):
deleteProjectMember(input: DeleteProjectMember!): DeleteProjectMemberPayload! CreateProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectMemberRole(input: UpdateProjectMemberRole!): UpdateProjectMemberRolePayload! deleteProjectMember(input: DeleteProjectMember!):
setProjectOwner(input: SetProjectOwner!): SetProjectOwnerPayload! DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectMemberRole(input: UpdateProjectMemberRole!):
UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }
input CreateProjectMember { input CreateProjectMember {
@ -293,16 +337,6 @@ type UpdateProjectMemberRolePayload {
member: Member! member: Member!
} }
input SetProjectOwner {
projectID: UUID!
ownerID: UUID!
}
type SetProjectOwnerPayload {
ok: Boolean!
prevOwner: Member!
newOwner: Member!
}
extend type Mutation { extend type Mutation {
createTask(input: NewTask!): Task! createTask(input: NewTask!): Task!
deleteTask(input: DeleteTaskInput!): DeleteTaskPayload! deleteTask(input: DeleteTaskInput!): DeleteTaskPayload!
@ -506,8 +540,10 @@ extend type Mutation {
} }
extend type Mutation { extend type Mutation {
deleteTeam(input: DeleteTeam!): DeleteTeamPayload! deleteTeam(input: DeleteTeam!):
createTeam(input: NewTeam!): Team! DeleteTeamPayload! @hasRole(roles:[ ADMIN], level: TEAM, type: TEAM)
createTeam(input: NewTeam!):
Team! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
} }
input NewTeam { input NewTeam {
@ -526,10 +562,11 @@ type DeleteTeamPayload {
} }
extend type Mutation { extend type Mutation {
setTeamOwner(input: SetTeamOwner!): SetTeamOwnerPayload! createTeamMember(input: CreateTeamMember!): CreateTeamMemberPayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
createTeamMember(input: CreateTeamMember!): CreateTeamMemberPayload! updateTeamMemberRole(input: UpdateTeamMemberRole!):
updateTeamMemberRole(input: UpdateTeamMemberRole!): UpdateTeamMemberRolePayload! UpdateTeamMemberRolePayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
deleteTeamMember(input: DeleteTeamMember!): DeleteTeamMemberPayload! deleteTeamMember(input: DeleteTeamMember!): DeleteTeamMemberPayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
} }
input DeleteTeamMember { input DeleteTeamMember {
@ -562,29 +599,23 @@ input UpdateTeamMemberRole {
type UpdateTeamMemberRolePayload { type UpdateTeamMemberRolePayload {
ok: Boolean! ok: Boolean!
member: Member!
}
input SetTeamOwner {
teamID: UUID! teamID: UUID!
userID: UUID! member: Member!
}
type SetTeamOwnerPayload {
ok: Boolean!
prevOwner: Member!
newOwner: Member!
} }
extend type Mutation { extend type Mutation {
createRefreshToken(input: NewRefreshToken!): RefreshToken! createRefreshToken(input: NewRefreshToken!): RefreshToken!
createUserAccount(input: NewUserAccount!): UserAccount! createUserAccount(input: NewUserAccount!):
deleteUserAccount(input: DeleteUserAccount!): DeleteUserAccountPayload! UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteUserAccount(input: DeleteUserAccount!):
DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
logoutUser(input: LogoutUser!): Boolean! logoutUser(input: LogoutUser!): Boolean!
clearProfileAvatar: UserAccount! clearProfileAvatar: UserAccount!
updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload! updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload!
updateUserRole(input: UpdateUserRole!): UpdateUserRolePayload! updateUserRole(input: UpdateUserRole!):
UpdateUserRolePayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
} }
input UpdateUserPassword { input UpdateUserPassword {

View File

@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror" "github.com/vektah/gqlparser/v2/gqlerror"
@ -23,7 +24,8 @@ func (r *labelColorResolver) ID(ctx context.Context, obj *db.LabelColor) (uuid.U
func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject) (*db.Project, error) { func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject) (*db.Project, error) {
createdAt := time.Now().UTC() createdAt := time.Now().UTC()
project, err := r.Repository.CreateProject(ctx, db.CreateProjectParams{input.UserID, input.TeamID, createdAt, input.Name}) log.WithFields(log.Fields{"userID": input.UserID, "name": input.Name, "teamID": input.TeamID}).Info("creating new project")
project, err := r.Repository.CreateProject(ctx, db.CreateProjectParams{input.TeamID, createdAt, input.Name})
return &project, err return &project, err
} }
@ -146,9 +148,6 @@ func (r *mutationResolver) DeleteProjectMember(ctx context.Context, input Delete
} }
func (r *mutationResolver) UpdateProjectMemberRole(ctx context.Context, input UpdateProjectMemberRole) (*UpdateProjectMemberRolePayload, error) { func (r *mutationResolver) UpdateProjectMemberRole(ctx context.Context, input UpdateProjectMemberRole) (*UpdateProjectMemberRolePayload, error) {
if input.RoleCode == RoleCodeOwner {
return &UpdateProjectMemberRolePayload{Ok: false}, errors.New("can not set project owner through this mutation")
}
user, err := r.Repository.GetUserAccountByID(ctx, input.UserID) user, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err != nil { if err != nil {
log.WithError(err).Error("get user account") log.WithError(err).Error("get user account")
@ -179,64 +178,6 @@ func (r *mutationResolver) UpdateProjectMemberRole(ctx context.Context, input Up
return &UpdateProjectMemberRolePayload{Ok: true, Member: &member}, err return &UpdateProjectMemberRolePayload{Ok: true, Member: &member}, err
} }
func (r *mutationResolver) SetProjectOwner(ctx context.Context, input SetProjectOwner) (*SetProjectOwnerPayload, error) {
project, err := r.Repository.GetProjectByID(ctx, input.ProjectID)
if project.Owner == input.OwnerID {
return &SetProjectOwnerPayload{Ok: false}, errors.New("new project owner is already project owner")
}
_, err = r.Repository.SetProjectOwner(ctx, db.SetProjectOwnerParams{Owner: input.OwnerID, ProjectID: input.ProjectID})
if err != nil {
return &SetProjectOwnerPayload{Ok: false}, err
}
err = r.Repository.DeleteProjectMember(ctx, db.DeleteProjectMemberParams{ProjectID: input.ProjectID, UserID: input.OwnerID})
if err != nil {
return &SetProjectOwnerPayload{Ok: false}, err
}
addedAt := time.Now().UTC()
_, err = r.Repository.CreateProjectMember(ctx, db.CreateProjectMemberParams{ProjectID: input.ProjectID,
UserID: project.Owner, RoleCode: RoleCodeAdmin.String(), AddedAt: addedAt})
if err != nil {
return &SetProjectOwnerPayload{Ok: false}, err
}
oldUser, err := r.Repository.GetUserAccountByID(ctx, project.Owner)
var url *string
if oldUser.ProfileAvatarUrl.Valid {
url = &oldUser.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &oldUser.Initials, &oldUser.ProfileBgColor}
oldUserRole := db.Role{Code: "admin", Name: "Admin"}
oldMember := &Member{
ID: oldUser.UserID,
Username: oldUser.Username,
FullName: oldUser.FullName,
ProfileIcon: profileIcon,
Role: &oldUserRole,
}
newUser, err := r.Repository.GetUserAccountByID(ctx, input.OwnerID)
if newUser.ProfileAvatarUrl.Valid {
url = &newUser.ProfileAvatarUrl.String
}
profileIcon = &ProfileIcon{url, &newUser.Initials, &newUser.ProfileBgColor}
newUserRole := db.Role{Code: "owner", Name: "Owner"}
newMember := &Member{
ID: newUser.UserID,
Username: newUser.Username,
FullName: newUser.FullName,
ProfileIcon: profileIcon,
Role: &newUserRole,
}
return &SetProjectOwnerPayload{
Ok: true,
PrevOwner: oldMember,
NewOwner: newMember,
}, nil
}
func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.Task, error) { func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.Task, error) {
taskGroupID, err := uuid.Parse(input.TaskGroupID) taskGroupID, err := uuid.Parse(input.TaskGroupID)
createdAt := time.Now().UTC() createdAt := time.Now().UTC()
@ -574,71 +515,21 @@ func (r *mutationResolver) DeleteTeam(ctx context.Context, input DeleteTeam) (*D
} }
func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*db.Team, error) { func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*db.Team, error) {
userID, ok := GetUserID(ctx) _, role, ok := GetUser(ctx)
if !ok { if !ok {
return &db.Team{}, fmt.Errorf("internal server error") return &db.Team{}, nil
} }
if role == auth.RoleAdmin {
createdAt := time.Now().UTC() createdAt := time.Now().UTC()
team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name, Owner: userID}) team, err := r.Repository.CreateTeam(ctx, db.CreateTeamParams{OrganizationID: input.OrganizationID, CreatedAt: createdAt, Name: input.Name})
return &team, err return &team, err
} }
return &db.Team{}, &gqlerror.Error{
func (r *mutationResolver) SetTeamOwner(ctx context.Context, input SetTeamOwner) (*SetTeamOwnerPayload, error) { Message: "You must be an organization admin to create new teams",
team, err := r.Repository.GetTeamByID(ctx, input.TeamID) Extensions: map[string]interface{}{
if team.Owner == input.UserID { "code": "1-400",
return &SetTeamOwnerPayload{Ok: false}, errors.New("new project owner is already project owner") },
} }
_, err = r.Repository.SetTeamOwner(ctx, db.SetTeamOwnerParams{Owner: input.UserID, TeamID: input.TeamID})
if err != nil {
return &SetTeamOwnerPayload{Ok: false}, errors.New("new project owner is already project owner")
}
err = r.Repository.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{TeamID: input.TeamID, UserID: input.UserID})
if err != nil {
return &SetTeamOwnerPayload{Ok: false}, errors.New("new project owner is already project owner")
}
addedAt := time.Now().UTC()
_, err = r.Repository.CreateTeamMember(ctx, db.CreateTeamMemberParams{TeamID: input.TeamID,
UserID: team.Owner, RoleCode: RoleCodeAdmin.String(), Addeddate: addedAt})
if err != nil {
return &SetTeamOwnerPayload{Ok: false}, errors.New("new project owner is already project owner")
}
oldUser, err := r.Repository.GetUserAccountByID(ctx, team.Owner)
var url *string
if oldUser.ProfileAvatarUrl.Valid {
url = &oldUser.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &oldUser.Initials, &oldUser.ProfileBgColor}
oldUserRole := db.Role{Code: "admin", Name: "Admin"}
oldMember := &Member{
ID: oldUser.UserID,
Username: oldUser.Username,
FullName: oldUser.FullName,
ProfileIcon: profileIcon,
Role: &oldUserRole,
}
newUser, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if newUser.ProfileAvatarUrl.Valid {
url = &newUser.ProfileAvatarUrl.String
}
profileIcon = &ProfileIcon{url, &newUser.Initials, &newUser.ProfileBgColor}
newUserRole := db.Role{Code: "owner", Name: "Owner"}
newMember := &Member{
ID: newUser.UserID,
Username: newUser.Username,
FullName: newUser.FullName,
ProfileIcon: profileIcon,
Role: &newUserRole,
}
return &SetTeamOwnerPayload{
Ok: true,
PrevOwner: oldMember,
NewOwner: newMember,
}, nil
} }
func (r *mutationResolver) CreateTeamMember(ctx context.Context, input CreateTeamMember) (*CreateTeamMemberPayload, error) { func (r *mutationResolver) CreateTeamMember(ctx context.Context, input CreateTeamMember) (*CreateTeamMemberPayload, error) {
@ -669,9 +560,6 @@ func (r *mutationResolver) CreateTeamMember(ctx context.Context, input CreateTea
} }
func (r *mutationResolver) UpdateTeamMemberRole(ctx context.Context, input UpdateTeamMemberRole) (*UpdateTeamMemberRolePayload, error) { func (r *mutationResolver) UpdateTeamMemberRole(ctx context.Context, input UpdateTeamMemberRole) (*UpdateTeamMemberRolePayload, error) {
if input.RoleCode == RoleCodeOwner || input.RoleCode == RoleCodeObserver {
return &UpdateTeamMemberRolePayload{Ok: false}, errors.New("can not set project owner through this mutation")
}
user, err := r.Repository.GetUserAccountByID(ctx, input.UserID) user, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err != nil { if err != nil {
log.WithError(err).Error("get user account") log.WithError(err).Error("get user account")
@ -699,29 +587,12 @@ func (r *mutationResolver) UpdateTeamMemberRole(ctx context.Context, input Updat
member := Member{ID: user.UserID, FullName: user.FullName, ProfileIcon: profileIcon, member := Member{ID: user.UserID, FullName: user.FullName, ProfileIcon: profileIcon,
Role: &db.Role{Code: role.Code, Name: role.Name}, Role: &db.Role{Code: role.Code, Name: role.Name},
} }
return &UpdateTeamMemberRolePayload{Ok: true, Member: &member}, err return &UpdateTeamMemberRolePayload{Ok: true, Member: &member, TeamID: input.TeamID}, err
} }
func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTeamMember) (*DeleteTeamMemberPayload, error) { func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTeamMember) (*DeleteTeamMemberPayload, error) {
ownedProjects, err := r.Repository.GetOwnedTeamProjectsForUserID(ctx, db.GetOwnedTeamProjectsForUserIDParams{TeamID: input.TeamID, Owner: input.UserID}) err := r.Repository.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{TeamID: input.TeamID, UserID: input.UserID})
if err != nil { return &DeleteTeamMemberPayload{TeamID: input.TeamID, UserID: input.UserID}, err
return &DeleteTeamMemberPayload{}, err
}
_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID})
if err != nil {
return &DeleteTeamMemberPayload{}, err
}
err = r.Repository.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{TeamID: input.TeamID, UserID: input.UserID})
if err != nil {
return &DeleteTeamMemberPayload{}, err
}
if input.NewOwnerID != nil {
for _, projectID := range ownedProjects {
_, err = r.Repository.SetProjectOwner(ctx, db.SetProjectOwnerParams{ProjectID: projectID, Owner: *input.NewOwnerID})
}
}
return &DeleteTeamMemberPayload{TeamID: input.TeamID, UserID: input.UserID}, nil
} }
func (r *mutationResolver) CreateRefreshToken(ctx context.Context, input NewRefreshToken) (*db.RefreshToken, error) { func (r *mutationResolver) CreateRefreshToken(ctx context.Context, input NewRefreshToken) (*db.RefreshToken, error) {
@ -733,6 +604,18 @@ func (r *mutationResolver) CreateRefreshToken(ctx context.Context, input NewRefr
} }
func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*db.UserAccount, error) { func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*db.UserAccount, error) {
_, role, ok := GetUser(ctx)
if !ok {
return &db.UserAccount{}, nil
}
if role != auth.RoleAdmin {
return &db.UserAccount{}, &gqlerror.Error{
Message: "Must be an organization admin",
Extensions: map[string]interface{}{
"code": "0-400",
},
}
}
createdAt := time.Now().UTC() createdAt := time.Now().UTC()
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(input.Password), 14) hashedPwd, err := bcrypt.GenerateFromPassword([]byte(input.Password), 14)
if err != nil { if err != nil {
@ -751,35 +634,25 @@ func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserA
} }
func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUserAccount) (*DeleteUserAccountPayload, error) { func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUserAccount) (*DeleteUserAccountPayload, error) {
_, role, ok := GetUser(ctx)
if !ok {
return &DeleteUserAccountPayload{Ok: false}, nil
}
if role != auth.RoleAdmin {
return &DeleteUserAccountPayload{Ok: false}, &gqlerror.Error{
Message: "User not found",
Extensions: map[string]interface{}{
"code": "0-401",
},
}
}
user, err := r.Repository.GetUserAccountByID(ctx, input.UserID) user, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err != nil { if err != nil {
return &DeleteUserAccountPayload{Ok: false}, err return &DeleteUserAccountPayload{Ok: false}, err
} }
var newOwnerID uuid.UUID // TODO(jordanknott) migrate admin ownership
if input.NewOwnerID == nil {
sysUser, err := r.Repository.GetUserAccountByUsername(ctx, "system")
if err != nil {
return &DeleteUserAccountPayload{Ok: false}, err
}
newOwnerID = sysUser.UserID
} else {
newOwnerID = *input.NewOwnerID
}
projectIDs, err := r.Repository.UpdateProjectOwnerByOwnerID(ctx, db.UpdateProjectOwnerByOwnerIDParams{Owner: user.UserID, Owner_2: newOwnerID})
if err != sql.ErrNoRows && err != nil {
return &DeleteUserAccountPayload{Ok: false}, err
}
for _, projectID := range projectIDs {
r.Repository.DeleteProjectMember(ctx, db.DeleteProjectMemberParams{UserID: newOwnerID, ProjectID: projectID})
}
teamIDs, err := r.Repository.UpdateTeamOwnerByOwnerID(ctx, db.UpdateTeamOwnerByOwnerIDParams{Owner: user.UserID, Owner_2: newOwnerID})
if err != sql.ErrNoRows && err != nil {
return &DeleteUserAccountPayload{Ok: false}, err
}
for _, teamID := range teamIDs {
r.Repository.DeleteTeamMember(ctx, db.DeleteTeamMemberParams{UserID: newOwnerID, TeamID: teamID})
}
err = r.Repository.DeleteUserAccountByID(ctx, input.UserID) err = r.Repository.DeleteUserAccountByID(ctx, input.UserID)
if err != nil { if err != nil {
return &DeleteUserAccountPayload{Ok: false}, err return &DeleteUserAccountPayload{Ok: false}, err
@ -822,6 +695,18 @@ func (r *mutationResolver) UpdateUserPassword(ctx context.Context, input UpdateU
} }
func (r *mutationResolver) UpdateUserRole(ctx context.Context, input UpdateUserRole) (*UpdateUserRolePayload, error) { func (r *mutationResolver) UpdateUserRole(ctx context.Context, input UpdateUserRole) (*UpdateUserRolePayload, error) {
_, role, ok := GetUser(ctx)
if !ok {
return &UpdateUserRolePayload{}, nil
}
if role != auth.RoleAdmin {
return &UpdateUserRolePayload{}, &gqlerror.Error{
Message: "User not found",
Extensions: map[string]interface{}{
"code": "0-401",
},
}
}
user, err := r.Repository.UpdateUserRole(ctx, db.UpdateUserRoleParams{RoleCode: input.RoleCode.String(), UserID: input.UserID}) user, err := r.Repository.UpdateUserRole(ctx, db.UpdateUserRoleParams{RoleCode: input.RoleCode.String(), UserID: input.UserID})
if err != nil { if err != nil {
return &UpdateUserRolePayload{}, err return &UpdateUserRolePayload{}, err
@ -839,26 +724,11 @@ func (r *projectResolver) ID(ctx context.Context, obj *db.Project) (uuid.UUID, e
func (r *projectResolver) Team(ctx context.Context, obj *db.Project) (*db.Team, error) { func (r *projectResolver) Team(ctx context.Context, obj *db.Project) (*db.Team, error) {
team, err := r.Repository.GetTeamByID(ctx, obj.TeamID) team, err := r.Repository.GetTeamByID(ctx, obj.TeamID)
if err != nil {
log.WithFields(log.Fields{"teamID": obj.TeamID, "projectID": obj.ProjectID}).WithError(err).Error("issue while getting team for project")
return &team, err return &team, err
} }
return &team, nil
func (r *projectResolver) Owner(ctx context.Context, obj *db.Project) (*Member, error) {
user, err := r.Repository.GetUserAccountByID(ctx, obj.Owner)
if err != nil {
return &Member{}, err
}
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
role, err := r.Repository.GetRoleForProjectMemberByUserID(ctx, db.GetRoleForProjectMemberByUserIDParams{UserID: user.UserID, ProjectID: obj.ProjectID})
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
return &Member{ID: obj.Owner, FullName: user.FullName, ProfileIcon: profileIcon,
Role: &db.Role{Code: role.Code, Name: role.Name},
}, nil
} }
func (r *projectResolver) TaskGroups(ctx context.Context, obj *db.Project) ([]db.TaskGroup, error) { func (r *projectResolver) TaskGroups(ctx context.Context, obj *db.Project) ([]db.TaskGroup, error) {
@ -866,24 +736,7 @@ func (r *projectResolver) TaskGroups(ctx context.Context, obj *db.Project) ([]db
} }
func (r *projectResolver) Members(ctx context.Context, obj *db.Project) ([]Member, error) { func (r *projectResolver) Members(ctx context.Context, obj *db.Project) ([]Member, error) {
user, err := r.Repository.GetUserAccountByID(ctx, obj.Owner)
members := []Member{} members := []Member{}
if err == sql.ErrNoRows {
return members, nil
}
if err != nil {
log.WithError(err).Error("get user account by ID")
return members, err
}
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
members = append(members, Member{
ID: obj.Owner, FullName: user.FullName, ProfileIcon: profileIcon, Username: user.Username,
Role: &db.Role{Code: "owner", Name: "Owner"},
})
projectMembers, err := r.Repository.GetProjectMembersForProjectID(ctx, obj.ProjectID) projectMembers, err := r.Repository.GetProjectMembersForProjectID(ctx, obj.ProjectID)
if err != nil { if err != nil {
log.WithError(err).Error("get project members for project id") log.WithError(err).Error("get project members for project id")
@ -891,7 +744,7 @@ func (r *projectResolver) Members(ctx context.Context, obj *db.Project) ([]Membe
} }
for _, projectMember := range projectMembers { for _, projectMember := range projectMembers {
user, err = r.Repository.GetUserAccountByID(ctx, projectMember.UserID) user, err := r.Repository.GetUserAccountByID(ctx, projectMember.UserID)
if err != nil { if err != nil {
log.WithError(err).Error("get user account by ID") log.WithError(err).Error("get user account by ID")
return members, err return members, err
@ -964,11 +817,12 @@ func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*db.UserA
} }
func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db.Project, error) { func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db.Project, error) {
projectID, err := uuid.Parse(input.ProjectID) userID, role, ok := GetUser(ctx)
if err != nil { log.WithFields(log.Fields{"userID": userID, "role": role}).Info("find project user")
return &db.Project{}, err if !ok {
return &db.Project{}, nil
} }
project, err := r.Repository.GetProjectByID(ctx, projectID) project, err := r.Repository.GetProjectByID(ctx, input.ProjectID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return &db.Project{}, &gqlerror.Error{ return &db.Project{}, &gqlerror.Error{
Message: "Project not found", Message: "Project not found",
@ -977,21 +831,87 @@ func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db
}, },
} }
} }
if role == auth.RoleAdmin {
return &project, nil
}
projectRoles, err := GetProjectRoles(ctx, r.Repository, input.ProjectID)
log.WithFields(log.Fields{"projectID": input.ProjectID, "teamRole": projectRoles.TeamRole, "projectRole": projectRoles.ProjectRole}).Info("get project roles ")
if err != nil {
return &project, err return &project, err
} }
if projectRoles.TeamRole == "" && projectRoles.ProjectRole == "" {
return &db.Project{}, &gqlerror.Error{
Message: "project not accessible",
Extensions: map[string]interface{}{
"code": "11-400",
},
}
}
return &project, nil
}
func (r *queryResolver) FindTask(ctx context.Context, input FindTask) (*db.Task, error) { func (r *queryResolver) FindTask(ctx context.Context, input FindTask) (*db.Task, error) {
task, err := r.Repository.GetTaskByID(ctx, input.TaskID) task, err := r.Repository.GetTaskByID(ctx, input.TaskID)
return &task, err return &task, err
} }
func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]db.Project, error) { func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]db.Project, error) {
userID, orgRole, ok := GetUser(ctx)
if !ok {
log.Info("user id was not found from middleware")
return []db.Project{}, nil
}
log.WithFields(log.Fields{"userID": userID}).Info("fetching projects")
if input != nil { if input != nil {
return r.Repository.GetAllProjectsForTeam(ctx, *input.TeamID) return r.Repository.GetAllProjectsForTeam(ctx, *input.TeamID)
} }
if orgRole == "admin" {
log.Info("showing all projects for admin")
return r.Repository.GetAllProjects(ctx) return r.Repository.GetAllProjects(ctx)
} }
teams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
projects := make(map[string]db.Project)
for _, team := range teams {
log.WithFields(log.Fields{"teamID": team.TeamID}).Info("found team")
teamProjects, err := r.Repository.GetAllProjectsForTeam(ctx, team.TeamID)
if err != sql.ErrNoRows && err != nil {
log.Info("issue getting team projects")
return []db.Project{}, nil
}
for _, project := range teamProjects {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("adding team project")
projects[project.ProjectID.String()] = project
}
}
visibleProjects, err := r.Repository.GetAllVisibleProjectsForUserID(ctx, userID)
if err != nil {
log.Info("user id was not found from middleware")
return []db.Project{}, nil
}
for _, project := range visibleProjects {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("found visible project")
if _, ok := projects[project.ProjectID.String()]; !ok {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("adding visible project")
projects[project.ProjectID.String()] = project
}
}
log.WithFields(log.Fields{"projectLength": len(projects)}).Info("making projects")
allProjects := make([]db.Project, 0, len(projects))
for _, project := range projects {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("add project to final list")
allProjects = append(allProjects, project)
}
log.Info(allProjects)
return allProjects, nil
}
func (r *queryResolver) FindTeam(ctx context.Context, input FindTeam) (*db.Team, error) { func (r *queryResolver) FindTeam(ctx context.Context, input FindTeam) (*db.Team, error) {
team, err := r.Repository.GetTeamByID(ctx, input.TeamID) team, err := r.Repository.GetTeamByID(ctx, input.TeamID)
if err != nil { if err != nil {
@ -1001,9 +921,49 @@ func (r *queryResolver) FindTeam(ctx context.Context, input FindTeam) (*db.Team,
} }
func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) { func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
userID, orgRole, ok := GetUser(ctx)
if !ok {
log.Error("userID or orgRole does not exist!")
return []db.Team{}, errors.New("internal error")
}
if orgRole == "admin" {
return r.Repository.GetAllTeams(ctx) return r.Repository.GetAllTeams(ctx)
} }
teams := make(map[string]db.Team)
adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
if err != nil {
return []db.Team{}, err
}
for _, team := range adminTeams {
teams[team.TeamID.String()] = team
}
visibleProjects, err := r.Repository.GetAllVisibleProjectsForUserID(ctx, userID)
if err != nil {
log.Info("user id was not found from middleware")
return []db.Team{}, err
}
for _, project := range visibleProjects {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("found visible project")
if _, ok := teams[project.ProjectID.String()]; !ok {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("adding visible project")
team, err := r.Repository.GetTeamByID(ctx, project.TeamID)
if err != nil {
log.Info("user id was not found from middleware")
return []db.Team{}, err
}
teams[project.TeamID.String()] = team
}
}
foundTeams := make([]db.Team, 0, len(teams))
for _, team := range teams {
foundTeams = append(foundTeams, team)
}
return foundTeams, nil
}
func (r *queryResolver) LabelColors(ctx context.Context) ([]db.LabelColor, error) { func (r *queryResolver) LabelColors(ctx context.Context) ([]db.LabelColor, error) {
return r.Repository.GetLabelColors(ctx) return r.Repository.GetLabelColors(ctx)
} }
@ -1012,19 +972,37 @@ func (r *queryResolver) TaskGroups(ctx context.Context) ([]db.TaskGroup, error)
return r.Repository.GetAllTaskGroups(ctx) return r.Repository.GetAllTaskGroups(ctx)
} }
func (r *queryResolver) Me(ctx context.Context) (*db.UserAccount, error) { func (r *queryResolver) Me(ctx context.Context) (*MePayload, error) {
userID, ok := GetUserID(ctx) userID, ok := GetUserID(ctx)
if !ok { if !ok {
return &db.UserAccount{}, fmt.Errorf("internal server error") return &MePayload{}, fmt.Errorf("internal server error")
} }
user, err := r.Repository.GetUserAccountByID(ctx, userID) user, err := r.Repository.GetUserAccountByID(ctx, userID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
log.WithFields(log.Fields{"userID": userID}).Warning("can not find user for me query") log.WithFields(log.Fields{"userID": userID}).Warning("can not find user for me query")
return &db.UserAccount{}, nil return &MePayload{}, nil
} else if err != nil { } else if err != nil {
return &db.UserAccount{}, err return &MePayload{}, err
} }
return &user, err var projectRoles []ProjectRole
projects, err := r.Repository.GetProjectRolesForUserID(ctx, userID)
if err != nil {
return &MePayload{}, err
}
for _, project := range projects {
projectRoles = append(projectRoles, ProjectRole{ProjectID: project.ProjectID, RoleCode: ConvertToRoleCode("admin")})
// projectRoles = append(projectRoles, ProjectRole{ProjectID: project.ProjectID, RoleCode: ConvertToRoleCode(project.RoleCode)})
}
var teamRoles []TeamRole
teams, err := r.Repository.GetTeamRolesForUserID(ctx, userID)
if err != nil {
return &MePayload{}, err
}
for _, team := range teams {
// teamRoles = append(teamRoles, TeamRole{TeamID: team.TeamID, RoleCode: ConvertToRoleCode(team.RoleCode)})
teamRoles = append(teamRoles, TeamRole{TeamID: team.TeamID, RoleCode: ConvertToRoleCode("admin")})
}
return &MePayload{User: &user, TeamRoles: teamRoles, ProjectRoles: projectRoles}, err
} }
func (r *refreshTokenResolver) ID(ctx context.Context, obj *db.RefreshToken) (uuid.UUID, error) { func (r *refreshTokenResolver) ID(ctx context.Context, obj *db.RefreshToken) (uuid.UUID, error) {
@ -1171,34 +1149,8 @@ func (r *teamResolver) ID(ctx context.Context, obj *db.Team) (uuid.UUID, error)
} }
func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, error) { func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, error) {
user, err := r.Repository.GetUserAccountByID(ctx, obj.Owner)
members := []Member{} members := []Member{}
log.WithFields(log.Fields{"teamID": obj.TeamID}).Info("getting members")
if err == sql.ErrNoRows {
return members, nil
}
if err != nil {
log.WithError(err).Error("get user account by ID")
return members, err
}
ownedList, err := GetOwnedList(ctx, r.Repository, user)
if err != nil {
return members, err
}
memberList, err := GetMemberList(ctx, r.Repository, user)
if err != nil {
return members, err
}
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
members = append(members, Member{
ID: obj.Owner, FullName: user.FullName, ProfileIcon: profileIcon, Username: user.Username,
Owned: ownedList, Member: memberList, Role: &db.Role{Code: "owner", Name: "Owner"},
})
teamMembers, err := r.Repository.GetTeamMembersForTeamID(ctx, obj.TeamID) teamMembers, err := r.Repository.GetTeamMembersForTeamID(ctx, obj.TeamID)
if err != nil { if err != nil {
log.WithError(err).Error("get project members for project id") log.WithError(err).Error("get project members for project id")
@ -1206,7 +1158,7 @@ func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, err
} }
for _, teamMember := range teamMembers { for _, teamMember := range teamMembers {
user, err = r.Repository.GetUserAccountByID(ctx, teamMember.UserID) user, err := r.Repository.GetUserAccountByID(ctx, teamMember.UserID)
if err != nil { if err != nil {
log.WithError(err).Error("get user account by ID") log.WithError(err).Error("get user account by ID")
return members, err return members, err
@ -1262,15 +1214,7 @@ func (r *userAccountResolver) ProfileIcon(ctx context.Context, obj *db.UserAccou
} }
func (r *userAccountResolver) Owned(ctx context.Context, obj *db.UserAccount) (*OwnedList, error) { func (r *userAccountResolver) Owned(ctx context.Context, obj *db.UserAccount) (*OwnedList, error) {
ownedTeams, err := r.Repository.GetOwnedTeamsForUserID(ctx, obj.UserID) return &OwnedList{}, nil // TODO(jordanknott)
if err != sql.ErrNoRows && err != nil {
return &OwnedList{}, err
}
ownedProjects, err := r.Repository.GetOwnedProjectsForUserID(ctx, obj.UserID)
if err != sql.ErrNoRows && err != nil {
return &OwnedList{}, err
}
return &OwnedList{Teams: ownedTeams, Projects: ownedProjects}, nil
} }
func (r *userAccountResolver) Member(ctx context.Context, obj *db.UserAccount) (*MemberList, error) { func (r *userAccountResolver) Member(ctx context.Context, obj *db.UserAccount) (*MemberList, error) {
@ -1330,7 +1274,9 @@ func (r *Resolver) Task() TaskResolver { return &taskResolver{r} }
func (r *Resolver) TaskChecklist() TaskChecklistResolver { return &taskChecklistResolver{r} } func (r *Resolver) TaskChecklist() TaskChecklistResolver { return &taskChecklistResolver{r} }
// TaskChecklistItem returns TaskChecklistItemResolver implementation. // TaskChecklistItem returns TaskChecklistItemResolver implementation.
func (r *Resolver) TaskChecklistItem() TaskChecklistItemResolver { return &taskChecklistItemResolver{r} } func (r *Resolver) TaskChecklistItem() TaskChecklistItemResolver {
return &taskChecklistItemResolver{r}
}
// TaskGroup returns TaskGroupResolver implementation. // TaskGroup returns TaskGroupResolver implementation.
func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} } func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} }

View File

@ -97,7 +97,6 @@ type Project {
createdAt: Time! createdAt: Time!
name: String! name: String!
team: Team! team: Team!
owner: Member!
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
members: [Member!]! members: [Member!]!
labels: [ProjectLabel!]! labels: [ProjectLabel!]!

View File

@ -1,3 +1,23 @@
enum RoleLevel {
ADMIN
MEMBER
}
enum ActionLevel {
ORG
TEAM
PROJECT
}
enum ObjectType {
ORG
TEAM
PROJECT
TASK
}
directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!) on FIELD_DEFINITION
type Query { type Query {
organizations: [Organization!]! organizations: [Organization!]!
users: [UserAccount!]! users: [UserAccount!]!
@ -9,11 +29,27 @@ type Query {
teams: [Team!]! teams: [Team!]!
labelColors: [LabelColor!]! labelColors: [LabelColor!]!
taskGroups: [TaskGroup!]! taskGroups: [TaskGroup!]!
me: UserAccount! me: MePayload!
} }
type Mutation type Mutation
type TeamRole {
teamID: UUID!
roleCode: RoleCode!
}
type ProjectRole {
projectID: UUID!
roleCode: RoleCode!
}
type MePayload {
user: UserAccount!
teamRoles: [TeamRole!]!
projectRoles: [ProjectRole!]!
}
input ProjectsFilter { input ProjectsFilter {
teamID: UUID teamID: UUID
} }
@ -23,7 +59,7 @@ input FindUser {
} }
input FindProject { input FindProject {
projectId: String! projectID: UUID!
} }
input FindTask { input FindTask {

View File

@ -1,7 +1,9 @@
extend type Mutation { extend type Mutation {
createProject(input: NewProject!): Project! createProject(input: NewProject!): Project! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
deleteProject(input: DeleteProject!): DeleteProjectPayload! deleteProject(input: DeleteProject!):
updateProjectName(input: UpdateProjectName): Project! DeleteProjectPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectName(input: UpdateProjectName):
Project! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }
input NewProject { input NewProject {

View File

@ -1,9 +1,14 @@
extend type Mutation { extend type Mutation {
createProjectLabel(input: NewProjectLabel!): ProjectLabel! createProjectLabel(input: NewProjectLabel!):
deleteProjectLabel(input: DeleteProjectLabel!): ProjectLabel! ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectLabel(input: UpdateProjectLabel!): ProjectLabel! deleteProjectLabel(input: DeleteProjectLabel!):
updateProjectLabelName(input: UpdateProjectLabelName!): ProjectLabel! ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectLabelColor(input: UpdateProjectLabelColor!): ProjectLabel! updateProjectLabel(input: UpdateProjectLabel!):
ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectLabelName(input: UpdateProjectLabelName!):
ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectLabelColor(input: UpdateProjectLabelColor!):
ProjectLabel! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }
input NewProjectLabel { input NewProjectLabel {

View File

@ -1,8 +1,10 @@
extend type Mutation { extend type Mutation {
createProjectMember(input: CreateProjectMember!): CreateProjectMemberPayload! createProjectMember(input: CreateProjectMember!):
deleteProjectMember(input: DeleteProjectMember!): DeleteProjectMemberPayload! CreateProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectMemberRole(input: UpdateProjectMemberRole!): UpdateProjectMemberRolePayload! deleteProjectMember(input: DeleteProjectMember!):
setProjectOwner(input: SetProjectOwner!): SetProjectOwnerPayload! DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectMemberRole(input: UpdateProjectMemberRole!):
UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }
input CreateProjectMember { input CreateProjectMember {
@ -36,13 +38,3 @@ type UpdateProjectMemberRolePayload {
ok: Boolean! ok: Boolean!
member: Member! member: Member!
} }
input SetProjectOwner {
projectID: UUID!
ownerID: UUID!
}
type SetProjectOwnerPayload {
ok: Boolean!
prevOwner: Member!
newOwner: Member!
}

View File

@ -1,15 +1,24 @@
extend type Mutation { extend type Mutation {
createTask(input: NewTask!): Task! createTask(input: NewTask!):
deleteTask(input: DeleteTaskInput!): DeleteTaskPayload! Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTask(input: DeleteTaskInput!):
DeleteTaskPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskDescription(input: UpdateTaskDescriptionInput!): Task! updateTaskDescription(input: UpdateTaskDescriptionInput!):
updateTaskLocation(input: NewTaskLocation!): UpdateTaskLocationPayload! Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskName(input: UpdateTaskName!): Task! updateTaskLocation(input: NewTaskLocation!):
setTaskComplete(input: SetTaskComplete!): Task! UpdateTaskLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskDueDate(input: UpdateTaskDueDate!): Task! updateTaskName(input: UpdateTaskName!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
setTaskComplete(input: SetTaskComplete!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskDueDate(input: UpdateTaskDueDate!):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
assignTask(input: AssignTaskInput): Task! assignTask(input: AssignTaskInput):
unassignTask(input: UnassignTaskInput): Task! Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
unassignTask(input: UnassignTaskInput):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }
input NewTask { input NewTask {

View File

@ -1,14 +1,23 @@
extend type Mutation { extend type Mutation {
createTaskChecklist(input: CreateTaskChecklist!): TaskChecklist! createTaskChecklist(input: CreateTaskChecklist!):
deleteTaskChecklist(input: DeleteTaskChecklist!): DeleteTaskChecklistPayload! TaskChecklist! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskChecklistName(input: UpdateTaskChecklistName!): TaskChecklist! deleteTaskChecklist(input: DeleteTaskChecklist!):
createTaskChecklistItem(input: CreateTaskChecklistItem!): TaskChecklistItem! DeleteTaskChecklistPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskChecklistItemName(input: UpdateTaskChecklistItemName!): TaskChecklistItem! updateTaskChecklistName(input: UpdateTaskChecklistName!):
setTaskChecklistItemComplete(input: SetTaskChecklistItemComplete!): TaskChecklistItem! TaskChecklist! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskChecklistItem(input: DeleteTaskChecklistItem!): DeleteTaskChecklistItemPayload! createTaskChecklistItem(input: CreateTaskChecklistItem!):
TaskChecklistItem! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskChecklistItemName(input: UpdateTaskChecklistItemName!):
TaskChecklistItem! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
setTaskChecklistItemComplete(input: SetTaskChecklistItemComplete!):
TaskChecklistItem! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskChecklistItem(input: DeleteTaskChecklistItem!):
DeleteTaskChecklistItemPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskChecklistLocation(input: UpdateTaskChecklistLocation!):
UpdateTaskChecklistLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskChecklistItemLocation(input: UpdateTaskChecklistItemLocation!):
UpdateTaskChecklistItemLocationPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskChecklistLocation(input: UpdateTaskChecklistLocation!): UpdateTaskChecklistLocationPayload!
updateTaskChecklistItemLocation(input: UpdateTaskChecklistItemLocation!): UpdateTaskChecklistItemLocationPayload!
} }
input UpdateTaskChecklistItemLocation { input UpdateTaskChecklistItemLocation {

View File

@ -1,8 +1,12 @@
extend type Mutation { extend type Mutation {
createTaskGroup(input: NewTaskGroup!): TaskGroup! createTaskGroup(input: NewTaskGroup!):
updateTaskGroupLocation(input: NewTaskGroupLocation!): TaskGroup! TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskGroupName(input: UpdateTaskGroupName!): TaskGroup! updateTaskGroupLocation(input: NewTaskGroupLocation!):
deleteTaskGroup(input: DeleteTaskGroupInput!): DeleteTaskGroupPayload! TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateTaskGroupName(input: UpdateTaskGroupName!):
TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroup(input: DeleteTaskGroupInput!):
DeleteTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }
input NewTaskGroupLocation { input NewTaskGroupLocation {

View File

@ -16,7 +16,11 @@ type ToggleTaskLabelPayload {
task: Task! task: Task!
} }
extend type Mutation { extend type Mutation {
addTaskLabel(input: AddTaskLabelInput): Task! addTaskLabel(input: AddTaskLabelInput):
removeTaskLabel(input: RemoveTaskLabelInput): Task! Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
toggleTaskLabel(input: ToggleTaskLabelInput!): ToggleTaskLabelPayload! removeTaskLabel(input: RemoveTaskLabelInput):
Task! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
toggleTaskLabel(input: ToggleTaskLabelInput!):
ToggleTaskLabelPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
} }

View File

@ -1,6 +1,8 @@
extend type Mutation { extend type Mutation {
deleteTeam(input: DeleteTeam!): DeleteTeamPayload! deleteTeam(input: DeleteTeam!):
createTeam(input: NewTeam!): Team! DeleteTeamPayload! @hasRole(roles:[ ADMIN], level: TEAM, type: TEAM)
createTeam(input: NewTeam!):
Team! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
} }
input NewTeam { input NewTeam {

View File

@ -1,8 +1,11 @@
extend type Mutation { extend type Mutation {
setTeamOwner(input: SetTeamOwner!): SetTeamOwnerPayload! createTeamMember(input: CreateTeamMember!):
createTeamMember(input: CreateTeamMember!): CreateTeamMemberPayload! CreateTeamMemberPayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
updateTeamMemberRole(input: UpdateTeamMemberRole!): UpdateTeamMemberRolePayload! updateTeamMemberRole(input: UpdateTeamMemberRole!):
deleteTeamMember(input: DeleteTeamMember!): DeleteTeamMemberPayload! UpdateTeamMemberRolePayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
deleteTeamMember(input: DeleteTeamMember!):
DeleteTeamMemberPayload! @hasRole(roles: [ADMIN], level: TEAM, type: TEAM)
} }
input DeleteTeamMember { input DeleteTeamMember {
@ -35,16 +38,6 @@ input UpdateTeamMemberRole {
type UpdateTeamMemberRolePayload { type UpdateTeamMemberRolePayload {
ok: Boolean! ok: Boolean!
teamID: UUID!
member: Member! member: Member!
} }
input SetTeamOwner {
teamID: UUID!
userID: UUID!
}
type SetTeamOwnerPayload {
ok: Boolean!
prevOwner: Member!
newOwner: Member!
}

View File

@ -1,12 +1,16 @@
extend type Mutation { extend type Mutation {
createRefreshToken(input: NewRefreshToken!): RefreshToken! createRefreshToken(input: NewRefreshToken!): RefreshToken!
createUserAccount(input: NewUserAccount!): UserAccount! createUserAccount(input: NewUserAccount!):
deleteUserAccount(input: DeleteUserAccount!): DeleteUserAccountPayload! UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteUserAccount(input: DeleteUserAccount!):
DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
logoutUser(input: LogoutUser!): Boolean! logoutUser(input: LogoutUser!): Boolean!
clearProfileAvatar: UserAccount! clearProfileAvatar: UserAccount!
updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload! updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload!
updateUserRole(input: UpdateUserRole!): UpdateUserRolePayload! updateUserRole(input: UpdateUserRole!):
UpdateUserRolePayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
} }
input UpdateUserPassword { input UpdateUserPassword {

View File

@ -62,7 +62,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly) accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
@ -100,6 +100,13 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
return return
} }
user, err := h.repo.GetUserAccountByID(r.Context(), token.UserID)
if err != nil {
log.WithError(err).Error("user retrieve failure")
w.WriteHeader(http.StatusInternalServerError)
return
}
refreshCreatedAt := time.Now().UTC() refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{token.UserID, refreshCreatedAt, refreshExpiresAt}) refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{token.UserID, refreshCreatedAt, refreshExpiresAt})
@ -109,7 +116,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted) accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
@ -175,7 +182,7 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted) accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
@ -235,7 +242,7 @@ func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request)
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted) accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }

View File

@ -53,6 +53,7 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
} }
ctx := context.WithValue(r.Context(), "userID", userID) ctx := context.WithValue(r.Context(), "userID", userID)
ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted) ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted)
ctx = context.WithValue(ctx, "org_role", accessClaims.OrgRole)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

View File

@ -0,0 +1,4 @@
INSERT INTO project_member (user_id, project_id, added_at, role_code)
SELECT owner as user_id, project_id, NOW() as added_at, 'admin' as role_code
FROM project;
ALTER TABLE project DROP COLUMN owner;

View File

@ -0,0 +1,4 @@
INSERT INTO team_member (user_id, team_id, addeddate, role_code)
SELECT owner as user_id, team_id, NOW() as addeddate, 'admin' as role_code
FROM team ON CONFLICT ON CONSTRAINT team_member_team_id_user_id_key DO NOTHING;
ALTER TABLE team DROP COLUMN owner;