feat: redesign project sharing & initial registration

redesigned the project sharing popup to be a multi select dropdown
that populates the options by using the input as a fuzzy search filter
on the current users & invited users.

users can now also be directly invited by email from the project share
window. if invited this way, then the user will receive an email
that sends them to a registration page, then a confirmation page.

the initial registration was always redone so that it uses a similar
system to the above in that it now will accept the first registered
user if there are no other accounts (besides 'system').
This commit is contained in:
Jordan Knott 2020-10-20 18:52:09 -05:00
parent 6c7203a4aa
commit 7b6624ecc3
75 changed files with 5041 additions and 859 deletions

47
conf/air.toml Normal file
View File

@ -0,0 +1,47 @@
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "."
tmp_dir = "tmp"
[build]
# Just plain old shell command. You could use `make` as well.
cmd = "go build -o ./dist/taskcafe cmd/taskcafe/main.go"
# Binary file yields from `cmd`.
bin = "dist/taskcafe"
# Customize binary.
full_bin = "./dist/taskcafe web"
# Watch these filename extensions.
include_ext = ["go"]
# Ignore these filename extensions or directories.
exclude_dir = ["dist", "frontend"]
# Watch these directories if you specified.
include_dir = []
# Exclude files.
exclude_file = []
# This log file places in your tmp_dir.
log = "air.log"
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 1000 # ms
# Stop running old binary when build errors occur.
stop_on_error = true
# Send Interrupt signal before killing process (windows does not support this feature)
send_interrupt = false
# Delay after sending Interrupt signal
kill_delay = 500 # ms
[log]
# Show log time
time = false
[color]
# Customize each part's color. If no color found, use the raw app log.
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
# Delete tmp directory on exit
clean_on_exit = true

View File

@ -12,7 +12,7 @@ services:
volumes:
- taskcafe-postgres:/var/lib/postgresql/data
ports:
- 5432:5432
- 8855:5432
mailhog:
image: mailhog/mailhog:latest
restart: always

View File

@ -31,7 +31,9 @@
"@typescript-eslint/no-unused-vars": "off",
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
"no-case-declarations": "off",
"no-plusplus": "off",
"react/prop-types": 0,
"no-continue": "off",
"react/jsx-props-no-spreading": "off",
"no-param-reassign": "off",
"import/extensions": [

View File

@ -13,6 +13,7 @@
"@types/jwt-decode": "^2.2.1",
"@types/lodash": "^4.14.149",
"@types/node": "^12.0.0",
"@types/query-string": "^6.3.0",
"@types/react": "^16.9.21",
"@types/react-beautiful-dnd": "^12.1.1",
"@types/react-datepicker": "^2.11.0",
@ -39,8 +40,9 @@
"history": "^4.10.1",
"immer": "^6.0.3",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.15",
"lodash": "^4.17.20",
"prop-types": "^15.7.2",
"query-string": "^6.13.7",
"react": "^16.12.0",
"react-autosize-textarea": "^7.0.0",
"react-beautiful-dnd": "^13.0.0",

View File

@ -5,6 +5,7 @@ import GlobalTopNavbar from 'App/TopNavbar';
import {
useUsersQuery,
useDeleteUserAccountMutation,
useDeleteInvitedUserAccountMutation,
useCreateUserAccountMutation,
UsersDocument,
UsersQuery,
@ -176,6 +177,17 @@ const AdminRoute = () => {
const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser();
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.invitedUsers = cache.invitedUsers.filter(
u => u.id !== response.data.deleteInvitedUserAccount.invitedUser.id,
);
}),
);
},
});
const [deleteUser] = useDeleteUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
@ -215,11 +227,16 @@ const AdminRoute = () => {
<Admin
initialTab={0}
users={data.users}
invitedUsers={data.invitedUsers}
canInviteUser={user.roles.org === 'admin'}
onInviteUser={NOOP}
onUpdateUserPassword={() => {
hidePopup();
}}
onDeleteInvitedUser={invitedUserID => {
deleteInvitedUser({ variables: { invitedUserID } });
hidePopup();
}}
onDeleteUser={(userID, newOwnerID) => {
deleteUser({ variables: { userID, newOwnerID } });
hidePopup();

View File

@ -1,16 +1,20 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { Switch, Route, useHistory } from 'react-router-dom';
import * as H from 'history';
import Dashboard from 'Dashboard';
import Admin from 'Admin';
import Confirm from 'Confirm';
import Projects from 'Projects';
import Project from 'Projects/Project';
import Teams from 'Teams';
import Login from 'Auth';
import Install from 'Install';
import Register from 'Register';
import Profile from 'Profile';
import styled from 'styled-components';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context';
const MainContent = styled.div`
padding: 0 0 0 0;
@ -21,6 +25,50 @@ const MainContent = styled.div`
flex-grow: 1;
`;
const AuthorizedRoutes = () => {
const history = useHistory();
const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser();
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
}
}
setLoading(false);
});
}, []);
return loading ? null : (
<Switch>
<MainContent>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" component={Projects} />
<Route path="/projects/:projectID" component={Project} />
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
</MainContent>
</Switch>
);
};
type RoutesProps = {
history: H.History;
};
@ -28,15 +76,9 @@ type RoutesProps = {
const Routes: React.FC<RoutesProps> = () => (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/install" component={Install} />
<MainContent>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" component={Projects} />
<Route path="/projects/:projectID" component={Project} />
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
</MainContent>
<Route exact path="/register" component={Register} />
<Route exact path="/confirm" component={Confirm} />
<AuthorizedRoutes />
</Switch>
);

View File

@ -230,10 +230,12 @@ type GlobalTopNavbarProps = {
menuType?: Array<MenuItem>;
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
projectMembers?: null | Array<TaskUser>;
projectInvitedMembers?: null | Array<InvitedUser>;
onSaveProjectName?: (projectName: string) => void;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onSetTab?: (tab: number) => void;
onRemoveFromBoard?: (userID: string) => void;
onRemoveInvitedFromBoard?: (email: string) => void;
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
@ -246,8 +248,10 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { user, setUserRoles, setUser } = useCurrentUser();
@ -333,6 +337,34 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
return null;
}
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
if (member) {
showPopup(
$targetRef,
<MiniProfile
onRemoveFromBoard={() => {
if (onRemoveInvitedFromBoard) {
onRemoveInvitedFromBoard(member.email);
}
}}
invited
user={{
id: member.email,
fullName: member.email,
bio: 'Invited',
profileIcon: {
bgColor: '#000',
url: null,
initials: member.email.charAt(0),
},
}}
bio=""
/>,
);
}
};
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning =
@ -382,6 +414,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile}
onInvitedMemberProfile={onInvitedMemberProfile}
onInviteUser={onInviteUser}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
@ -392,6 +425,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
history.push('/');
}}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onProfileClick={onProfileClick}
onSaveName={onSaveProjectName}
onOpenSettings={onOpenSettings}

View File

@ -52,7 +52,6 @@ type RefreshTokenResponse = {
};
const App = () => {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<CurrentUserRaw | null>(null);
const setUserRoles = (roles: CurrentUserRoles) => {
if (user) {
@ -63,32 +62,6 @@ const App = () => {
}
};
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}
}
setLoading(false);
});
}, []);
return (
<>
<UserContext.Provider value={{ user, setUser, setUserRoles }}>
@ -97,13 +70,7 @@ const App = () => {
<BaseStyles />
<Router history={history}>
<PopupProvider>
{loading ? (
<div>loading</div>
) : (
<>
<Routes history={history} />
</>
)}
<Routes history={history} />
</PopupProvider>
</Router>
<StyledContainer

View File

@ -52,7 +52,20 @@ const Auth = () => {
}).then(async x => {
const { status } = x;
if (status === 200) {
history.replace('/projects');
const response: RefreshTokenResponse = await x.json();
const { accessToken, setup } = response;
if (setup) {
history.replace(`/register?confirmToken=${setup.confirmToken}`);
} else {
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: { org: claims.orgRole, teams: new Map<string, string>(), projects: new Map<string, string>() },
};
setUser(currentUser);
setAccessToken(accessToken);
history.replace('/projects');
}
}
});
}, []);

View File

@ -0,0 +1,61 @@
import React, { useState } from 'react';
import axios from 'axios';
import Confirm from 'shared/components/Confirm';
import { useHistory, useLocation } from 'react-router';
import * as QueryString from 'query-string';
import { toast } from 'react-toastify';
import { Container, LoginWrapper } from './Styles';
import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context';
const UsersConfirm = () => {
const history = useHistory();
const location = useLocation();
const [registered, setRegistered] = useState(false);
const params = QueryString.parse(location.search);
const { setUser } = useCurrentUser();
return (
<Container>
<LoginWrapper>
<Confirm
hasConfirmToken={params.confirmToken !== undefined}
onConfirmUser={setFailed => {
fetch('/auth/confirm', {
method: 'POST',
body: JSON.stringify({
confirmToken: params.confirmToken,
}),
})
.then(async x => {
const { status } = x;
if (status === 200) {
const response = await x.json();
const { accessToken } = response;
const claims: JWTToken = JwtDecode(accessToken);
const currentUser = {
id: claims.userId,
roles: {
org: claims.orgRole,
teams: new Map<string, string>(),
projects: new Map<string, string>(),
},
};
setUser(currentUser);
setAccessToken(accessToken);
history.push('/');
} else {
setFailed();
}
})
.catch(() => {
setFailed();
});
}}
/>
</LoginWrapper>
</Container>
);
};
export default UsersConfirm;

View File

@ -1,88 +0,0 @@
import React, { useEffect, useContext } from 'react';
import axios from 'axios';
import Register from 'shared/components/Register';
import { useHistory } from 'react-router';
import { getAccessToken, setAccessToken } from 'shared/utils/accessToken';
import UserContext from 'App/context';
import jwtDecode from 'jwt-decode';
import { Container, LoginWrapper } from './Styles';
const Install = () => {
const history = useHistory();
const { setUser } = useContext(UserContext);
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
const response: RefreshTokenResponse = await x.json();
const { isInstalled } = response;
if (status === 200 && isInstalled) {
history.replace('/projects');
}
});
}, []);
return (
<Container>
<LoginWrapper>
<Register
onSubmit={(data, setComplete, setError) => {
const accessToken = getAccessToken();
if (data.password !== data.password_confirm) {
setError('password', { type: 'error', message: 'Passwords must match' });
setError('password_confirm', { type: 'error', message: 'Passwords must match' });
} else {
axios
.post(
'/auth/install',
{
user: {
username: data.username,
roleCode: 'admin',
email: data.email,
password: data.password,
initials: data.initials,
fullname: data.fullname,
},
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
.then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.data;
const { accessToken: newToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(newToken);
const currentUser = {
id: claims.userId,
roles: {
org: claims.orgRole,
teams: new Map<string, string>(),
projects: new Map<string, string>(),
},
};
setUser(currentUser);
setAccessToken(newToken);
if (!isInstalled) {
history.replace('/install');
}
}
history.push('/projects');
});
}
setComplete(true);
}}
/>
</LoginWrapper>
</Container>
);
};
export default Install;

View File

@ -3,32 +3,11 @@ import updateApolloCache from 'shared/utils/cache';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import produce from 'immer';
import {
useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation,
useDeleteProjectMemberMutation,
useSetTaskCompleteMutation,
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation,
useUpdateProjectLabelMutation,
useCreateTaskMutation,
useDeleteProjectLabelMutation,
useDeleteTaskMutation,
useUpdateTaskLocationMutation,
useUpdateTaskGroupLocationMutation,
useCreateTaskGroupMutation,
useDeleteTaskGroupMutation,
useUpdateTaskDescriptionMutation,
useAssignTaskMutation,
DeleteTaskDocument,
FindProjectDocument,
useCreateProjectLabelMutation,
useUnassignTaskMutation,
useUpdateTaskDueDateMutation,
FindProjectQuery,
useUsersQuery,
} from 'shared/generated/graphql';
import LabelManager from 'shared/components/PopupMenu/LabelManager';
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';

View File

@ -3,6 +3,7 @@ import React, { useState, useRef, useEffect, useContext } from 'react';
import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import styled from 'styled-components/macro';
import AsyncSelect from 'react-select/async';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import {
useParams,
@ -15,11 +16,12 @@ import {
} from 'react-router-dom';
import {
useUpdateProjectMemberRoleMutation,
useCreateProjectMemberMutation,
useInviteProjectMembersMutation,
useDeleteProjectMemberMutation,
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useDeleteInvitedProjectMemberMutation,
useUpdateTaskNameMutation,
useCreateTaskMutation,
useDeleteTaskMutation,
@ -37,12 +39,20 @@ import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop';
import { Lock, Cross } from 'shared/icons';
import Button from 'shared/components/Button';
import { useApolloClient } from '@apollo/react-hooks';
import TaskAssignee from 'shared/components/TaskAssignee';
import gql from 'graphql-tag';
import { colourStyles } from 'shared/components/Select';
import Board, { BoardLoading } from './Board';
import Details from './Details';
import LabelManagerEditor from './LabelManagerEditor';
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
const RFC2822_EMAIL = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
@ -70,29 +80,299 @@ const MemberList = styled.div`
margin: 8px 0;
`;
type InviteUserData = {
email?: string;
suerID?: string;
};
type UserManagementPopupProps = {
projectID: string;
users: Array<User>;
projectMembers: Array<TaskUser>;
onAddProjectMember: (userID: string) => void;
onInviteProjectMembers: (data: Array<InviteUserData>) => void;
};
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => {
const VisibiltyPrivateIcon = styled(Lock)`
padding-right: 4px;
`;
const VisibiltyButtonText = styled.span`
color: rgba(${props => props.theme.colors.text.primary});
`;
const ShareActions = styled.div`
border-top: 1px solid #414561;
margin-top: 8px;
padding-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
`;
const VisibiltyButton = styled.button`
cursor: pointer;
margin: 2px 4px;
padding: 2px 4px;
align-items: center;
justify-content: center;
border-bottom: 1px solid transparent;
&:hover ${VisibiltyButtonText} {
color: rgba(${props => props.theme.colors.text.secondary});
}
&:hover ${VisibiltyPrivateIcon} {
fill: rgba(${props => props.theme.colors.text.secondary});
stroke: rgba(${props => props.theme.colors.text.secondary});
}
&:hover {
border-bottom: 1px solid rgba(${props => props.theme.colors.primary});
}
`;
type MemberFilterOptions = {
projectID?: null | string;
teamID?: null | string;
organization?: boolean;
};
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => {
console.log(input.trim().length < 3);
if (input && input.trim().length < 3) {
return [];
}
const res = await client.query({
query: gql`
query {
searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) {
id
similarity
status
user {
id
fullName
email
profileIcon {
url
initials
bgColor
}
}
}
}
`,
});
let results: any = [];
const emails: Array<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) {
results = [
...res.data.searchMembers.map((m: any) => {
if (m.status === 'INVITED') {
console.log(`${m.id} is added`);
return {
label: m.id,
value: {
id: m.id,
type: 2,
profileIcon: {
bgColor: '#ccc',
initials: m.id.charAt(0),
},
},
};
} else {
console.log(`${m.user.email} is added`);
emails.push(m.user.email);
return {
label: m.user.fullName,
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
};
}
}),
];
console.log(results);
}
if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
results = [
...results,
{
label: input,
value: {
id: input,
type: 1,
profileIcon: {
bgColor: '#ccc',
initials: input.charAt(0),
},
},
},
];
}
return results;
};
type UserOptionProps = {
innerProps: any;
isDisabled: boolean;
isFocused: boolean;
label: string;
data: any;
getValue: any;
};
const OptionWrapper = styled.div<{ isFocused: boolean }>`
cursor: pointer;
padding: 4px 8px;
${props => props.isFocused && `background: rgba(${props.theme.colors.primary});`}
display: flex;
align-items: center;
`;
const OptionContent = styled.div`
display: flex;
flex-direction: column;
margin-left: 12px;
`;
const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
display: flex;
align-items: center;
font-size: ${p => p.fontSize}px;
color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)});
`;
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data);
return !isDisabled ? (
<OptionWrapper {...innerProps} isFocused={isFocused}>
<TaskAssignee
size={32}
member={{
id: '',
fullName: data.value.label,
profileIcon: data.value.profileIcon,
}}
/>
<OptionContent>
<OptionLabel fontSize={16} quiet={false}>
{label}
</OptionLabel>
{data.value.type === 2 && (
<OptionLabel fontSize={14} quiet>
Joined
</OptionLabel>
)}
</OptionContent>
</OptionWrapper>
) : null;
};
const OptionValueWrapper = styled.div`
background: rgba(${props => props.theme.colors.bg.primary});
border-radius: 4px;
margin: 2px;
padding: 3px 6px 3px 4px;
display: flex;
align-items: center;
`;
const OptionValueLabel = styled.span`
font-size: 12px;
color: rgba(${props => props.theme.colors.text.secondary});
`;
const OptionValueRemove = styled.button`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
outline: none;
padding: 0;
margin: 0;
margin-left: 4px;
`;
const OptionValue = ({ data, removeProps }: any) => {
return (
<OptionValueWrapper>
<OptionValueLabel>{data.label}</OptionValueLabel>
<OptionValueRemove {...removeProps}>
<Cross width={14} height={14} />
</OptionValueRemove>
</OptionValueWrapper>
);
};
const InviteButton = styled(Button)`
margin-top: 12px;
height: 32px;
padding: 4px 12px;
width: 100%;
justify-content: center;
`;
const InviteContainer = styled.div`
min-height: 300px;
display: flex;
flex-direction: column;
`;
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({
projectID,
users,
projectMembers,
onInviteProjectMembers,
}) => {
const client = useApolloClient();
const [invitedUsers, setInvitedUsers] = useState<Array<any> | null>(null);
return (
<Popup tab={0} title="Invite a user">
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
<MemberList>
{users
.filter(u => u.id !== projectMembers.find(p => p.id === u.id)?.id)
.map(user => (
<UserMember
key={user.id}
onCardMemberClick={() => onAddProjectMember(user.id)}
showName
member={user}
taskID=""
/>
))}
</MemberList>
<InviteContainer>
<AsyncSelect
getOptionValue={option => option.value.id}
placeholder="Email address or username"
noOptionsMessage={() => null}
onChange={(e: any) => {
setInvitedUsers(e);
}}
isMulti
autoFocus
cacheOptions
styles={colourStyles}
defaultOption
components={{
MultiValue: OptionValue,
Option: UserOption,
IndicatorSeparator: null,
DropdownIndicator: null,
}}
loadOptions={(i, cb) => fetchMembers(client, projectID, {}, i, cb)}
/>
</InviteContainer>
<InviteButton
onClick={() => {
if (invitedUsers) {
onInviteProjectMembers(
invitedUsers.map(user => {
if (user.value.type === 0) {
return {
userID: user.value.id,
};
}
return {
email: user.value.id,
};
}),
);
}
}}
disabled={invitedUsers === null}
hoverVariant="none"
fontSize="16px"
>
Send Invite
</InviteButton>
</Popup>
);
};
@ -176,14 +456,36 @@ const Project = () => {
},
});
const [createProjectMember] = useCreateProjectMemberMutation({
const [inviteProjectMembers] = useInviteProjectMembersMutation({
update: (client, response) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
draftCache.findProject.members = [
...cache.findProject.members,
...response.data.inviteProjectMembers.members,
];
draftCache.findProject.invitedMembers = [
...cache.findProject.invitedMembers,
...response.data.inviteProjectMembers.invitedMembers,
];
}),
{ projectID },
);
},
});
const [deleteInvitedProjectMember] = useDeleteInvitedProjectMemberMutation({
update: (client, response) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
m => m.email !== response.data.deleteInvitedProjectMember.invitedMember.email,
);
}),
{ projectID },
);
@ -243,6 +545,10 @@ const Project = () => {
deleteProjectMember({ variables: { userID, projectID } });
hidePopup();
}}
onRemoveInvitedFromBoard={email => {
deleteInvitedProjectMember({ variables: { projectID, email } });
hidePopup();
}}
onSaveProjectName={projectName => {
updateProjectName({ variables: { projectID, name: projectName } });
}}
@ -250,8 +556,10 @@ const Project = () => {
showPopup(
$target,
<UserManagementPopup
onAddProjectMember={userID => {
createProjectMember({ variables: { userID, projectID } });
projectID={projectID}
onInviteProjectMembers={members => {
inviteProjectMembers({ variables: { projectID, members } });
hidePopup();
}}
users={data.users}
projectMembers={data.findProject.members}
@ -262,6 +570,7 @@ const Project = () => {
menuType={[{ name: 'Board', link: location.pathname }]}
currentTab={0}
projectMembers={data.findProject.members}
projectInvitedMembers={data.findProject.invitedMembers}
projectID={projectID}
teamID={data.findProject.team ? data.findProject.team.id : null}
name={data.findProject.name}

View File

@ -0,0 +1,13 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
`;
export const LoginWrapper = styled.div`
width: 60%;
`;

View File

@ -0,0 +1,62 @@
import React, { useState } from 'react';
import axios from 'axios';
import Register from 'shared/components/Register';
import { useHistory, useLocation } from 'react-router';
import * as QueryString from 'query-string';
import { toast } from 'react-toastify';
import { Container, LoginWrapper } from './Styles';
const UsersRegister = () => {
const history = useHistory();
const location = useLocation();
const [registered, setRegistered] = useState(false);
const params = QueryString.parse(location.search);
return (
<Container>
<LoginWrapper>
<Register
registered={registered}
onSubmit={(data, setComplete, setError) => {
if (data.password !== data.password_confirm) {
setError('password', { type: 'error', message: 'Passwords must match' });
setError('password_confirm', { type: 'error', message: 'Passwords must match' });
} else {
// TODO: change to fetch?
fetch('/auth/register', {
method: 'POST',
body: JSON.stringify({
user: {
username: data.username,
roleCode: 'admin',
email: data.email,
password: data.password,
initials: data.initials,
fullname: data.fullname,
},
}),
})
.then(async x => {
const response = await x.json();
const { setup } = response;
console.log(response);
if (setup) {
history.replace(`/confirm?confirmToken=xxxx`);
} else if (params.confirmToken) {
history.replace(`/confirm?confirmToken=${params.confirmToken}`);
} else {
setRegistered(true);
}
})
.catch(() => {
toast('There was an issue trying to register');
});
}
setComplete(true);
}}
/>
</LoginWrapper>
</Container>
);
};
export default UsersRegister;

0
frontend/src/outline.d.ts vendored Normal file
View File

View File

@ -51,7 +51,9 @@ export const Default = () => {
},
},
]}
invitedUsers={[]}
onAddUser={action('add user')}
onDeleteInvitedUser={action('delete invited user')}
/>
</ThemeProvider>
</>

View File

@ -104,8 +104,8 @@ type TeamRoleManagerPopupProps = {
user: User;
users: Array<User>;
warning?: string | null;
canChangeRole: boolean;
onChangeRole: (roleCode: RoleCode) => void;
canChangeRole?: boolean;
onChangeRole?: (roleCode: RoleCode) => void;
updateUserPassword?: (user: TaskUser, password: string) => void;
onDeleteUser?: (userID: string, newOwnerID: string | null) => void;
};
@ -530,8 +530,10 @@ type AdminProps = {
onDeleteUser: (userID: string, newOwnerID: string | null) => void;
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
users: Array<User>;
invitedUsers: Array<InvitedUserAccount>;
canInviteUser: boolean;
onUpdateUserPassword: (user: TaskUser, password: string) => void;
onDeleteInvitedUser: (invitedUserID: string) => void;
};
const Admin: React.FC<AdminProps> = ({
@ -540,7 +542,9 @@ const Admin: React.FC<AdminProps> = ({
onUpdateUserPassword,
canInviteUser,
onDeleteUser,
onDeleteInvitedUser,
onInviteUser,
invitedUsers,
users,
}) => {
const warning =
@ -577,7 +581,7 @@ const Admin: React.FC<AdminProps> = ({
<TabContent>
<MemberListWrapper>
<MemberListHeader>
<ListTitle>{`Members (${users.length})`}</ListTitle>
<ListTitle>{`Members (${users.length + invitedUsers.length})`}</ListTitle>
<ListDesc>
Organization admins can create / manage / delete all projects & teams. Members only have access to teams
or projects they have been added to.
@ -635,6 +639,65 @@ const Admin: React.FC<AdminProps> = ({
</MemberListItem>
);
})}
{invitedUsers.map(member => {
return (
<MemberListItem>
<MemberProfile
showRoleIcons
size={32}
onMemberProfile={NOOP}
member={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
}}
/>
<MemberListItemDetails>
<MemberItemName>{member.email}</MemberItemName>
<MemberItemUsername>Invited</MemberItemUsername>
</MemberListItemDetails>
<MemberItemOptions>
<MemberItemOption
variant="outline"
onClick={$target => {
showPopup(
$target,
<TeamRoleManagerPopup
user={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
member: {
teams: [],
projects: [],
},
owned: {
teams: [],
projects: [],
},
}}
users={users}
onDeleteUser={() => {
onDeleteInvitedUser(member.id);
}}
/>,
);
}}
>
Manage
</MemberItemOption>
</MemberItemOptions>
</MemberListItem>
);
})}
</MemberList>
</MemberListWrapper>
</TabContent>

View File

@ -35,11 +35,15 @@ const Base = styled.button<{ color: string; disabled: boolean }>`
`}
`;
const Filled = styled(Base)`
const Filled = styled(Base)<{ hoverVariant: HoverVariant }>`
background: rgba(${props => props.theme.colors[props.color]});
&:hover {
box-shadow: 0 8px 25px -8px rgba(${props => props.theme.colors[props.color]});
}
${props =>
props.hoverVariant === 'boxShadow' &&
css`
&:hover {
box-shadow: 0 8px 25px -8px rgba(${props.theme.colors[props.color]});
}
`}
`;
const Outline = styled(Base)<{ invert: boolean }>`
border: 1px solid rgba(${props => props.theme.colors[props.color]});
@ -123,9 +127,11 @@ const Relief = styled(Base)`
}
`;
type HoverVariant = 'boxShadow' | 'none';
type ButtonProps = {
fontSize?: string;
variant?: 'filled' | 'outline' | 'flat' | 'lineDown' | 'gradient' | 'relief';
hoverVariant?: HoverVariant;
color?: 'primary' | 'danger' | 'success' | 'warning' | 'dark';
disabled?: boolean;
type?: 'button' | 'submit';
@ -142,6 +148,7 @@ const Button: React.FC<ButtonProps> = ({
invert = false,
color = 'primary',
variant = 'filled',
hoverVariant = 'boxShadow',
type = 'button',
justifyTextContent = 'center',
icon,
@ -158,7 +165,15 @@ const Button: React.FC<ButtonProps> = ({
switch (variant) {
case 'filled':
return (
<Filled ref={$button} type={type} onClick={handleClick} className={className} disabled={disabled} color={color}>
<Filled
ref={$button}
hoverVariant={hoverVariant}
type={type}
onClick={handleClick}
className={className}
disabled={disabled}
color={color}
>
{icon && icon}
<Text hasIcon={typeof icon !== 'undefined'} justifyTextContent={justifyTextContent} fontSize={fontSize}>
{children}

View File

@ -0,0 +1,103 @@
import styled from 'styled-components';
import Button from 'shared/components/Button';
export const Wrapper = styled.div`
background: #eff2f7;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
`;
export const Column = styled.div`
width: 50%;
display: flex;
justify-content: center;
align-items: center;
`;
export const LoginFormWrapper = styled.div`
background: #10163a;
width: 100%;
`;
export const LoginFormContainer = styled.div`
min-height: 505px;
padding: 2rem;
`;
export const Title = styled.h1`
color: #ebeefd;
font-size: 18px;
margin-bottom: 14px;
`;
export const SubTitle = styled.h2`
color: #c2c6dc;
font-size: 14px;
margin-bottom: 14px;
`;
export const Form = styled.form`
display: flex;
flex-direction: column;
`;
export const FormLabel = styled.label`
color: #c2c6dc;
font-size: 12px;
position: relative;
margin-top: 14px;
`;
export const FormTextInput = styled.input`
width: 100%;
background: #262c49;
border: 1px solid rgba(0, 0, 0, 0.2);
margin-top: 4px;
padding: 0.7rem 1rem 0.7rem 3rem;
font-size: 1rem;
color: #c2c6dc;
border-radius: 5px;
`;
export const FormIcon = styled.div`
top: 30px;
left: 16px;
position: absolute;
`;
export const FormError = styled.span`
font-size: 0.875rem;
color: rgb(234, 84, 85);
`;
export const LoginButton = styled(Button)``;
export const ActionButtons = styled.div`
margin-top: 17.5px;
display: flex;
justify-content: space-between;
`;
export const RegisterButton = styled(Button)``;
export const LogoTitle = styled.div`
font-size: 24px;
font-weight: 600;
margin-left: 12px;
transition: visibility, opacity, transform 0.25s ease;
color: #7367f0;
`;
export const LogoWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
position: relative;
width: 100%;
padding-bottom: 16px;
margin-bottom: 24px;
color: rgb(222, 235, 255);
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
`;

View File

@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock, Taskcafe } from 'shared/icons';
import { useForm } from 'react-hook-form';
import LoadingSpinner from 'shared/components/LoadingSpinner';
import {
Form,
LogoWrapper,
LogoTitle,
ActionButtons,
RegisterButton,
FormError,
FormIcon,
FormLabel,
FormTextInput,
Wrapper,
Column,
LoginFormWrapper,
LoginFormContainer,
Title,
SubTitle,
} from './Styles';
const Confirm = ({ onConfirmUser, hasConfirmToken }: ConfirmProps) => {
const [hasFailed, setFailed] = useState(false);
const setHasFailed = () => {
setFailed(true);
};
useEffect(() => {
onConfirmUser(setHasFailed);
});
return (
<Wrapper>
<Column>
<AccessAccount width={275} height={250} />
</Column>
<Column>
<LoginFormWrapper>
<LoginFormContainer>
<LogoWrapper>
<Taskcafe width={42} height={42} />
<LogoTitle>Taskcafé</LogoTitle>
</LogoWrapper>
{hasConfirmToken ? (
<>
<Title>Confirming user...</Title>
{hasFailed ? <SubTitle>There was an error while confirming your user</SubTitle> : <LoadingSpinner />}
</>
) : (
<>
<Title>There is no confirmation token</Title>
<SubTitle>There seems to have been an error.</SubTitle>
</>
)}
</LoginFormContainer>
</LoginFormWrapper>
</Column>
</Wrapper>
);
};
export default Confirm;

View File

@ -73,7 +73,6 @@ export const HeaderName = styled(TextareaAutosize)`
box-shadow: none;
font-weight: 600;
margin: -4px 0;
padding: 4px 8px;
letter-spacing: normal;
word-spacing: normal;

View File

@ -47,6 +47,7 @@ const permissions = [
type MiniProfileProps = {
bio: string;
user: TaskUser;
invited?: boolean;
onRemoveFromTask?: () => void;
onChangeRole?: (roleCode: RoleCode) => void;
onRemoveFromBoard?: () => void;
@ -56,6 +57,7 @@ type MiniProfileProps = {
const MiniProfile: React.FC<MiniProfileProps> = ({
user,
bio,
invited,
canChangeRole,
onRemoveFromTask,
onChangeRole,
@ -74,7 +76,7 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
)}
<ProfileInfo>
<InfoTitle>{user.fullName}</InfoTitle>
<InfoUsername>{`@${user.username}`}</InfoUsername>
{invited ? <InfoUsername>Invited</InfoUsername> : <InfoUsername>{`@${user.username}`}</InfoUsername>}
<InfoBio>{bio}</InfoBio>
</ProfileInfo>
</Profile>

View File

@ -24,7 +24,7 @@ import {
const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
const INITIALS_PATTERN = /[a-zA-Z]{2,3}/i;
const Register = ({ onSubmit }: RegisterProps) => {
const Register = ({ onSubmit, registered = false }: RegisterProps) => {
const [isComplete, setComplete] = useState(true);
const { register, handleSubmit, errors, setError } = useForm<RegisterFormData>();
const loginSubmit = (data: RegisterFormData) => {
@ -43,103 +43,112 @@ const Register = ({ onSubmit }: RegisterProps) => {
<Taskcafe width={42} height={42} />
<LogoTitle>Taskcafé</LogoTitle>
</LogoWrapper>
<Title>Register</Title>
<SubTitle>Please create the system admin user</SubTitle>
<Form onSubmit={handleSubmit(loginSubmit)}>
<FormLabel htmlFor="fullname">
Full name
<FormTextInput
type="text"
id="fullname"
name="fullname"
ref={register({ required: 'Full name is required' })}
/>
<FormIcon>
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="username">
Username
<FormTextInput
type="text"
id="username"
name="username"
ref={register({ required: 'Username is required' })}
/>
<FormIcon>
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="email">
Email
<FormTextInput
type="text"
id="email"
name="email"
ref={register({
required: 'Email is required',
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
})}
/>
<FormIcon>
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.email && <FormError>{errors.email.message}</FormError>}
<FormLabel htmlFor="initials">
Initials
<FormTextInput
type="text"
id="initials"
name="initials"
ref={register({
required: 'Initials is required',
pattern: {
value: INITIALS_PATTERN,
message: 'Initials must be between 2 to 3 characters.',
},
})}
/>
<FormIcon>
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.initials && <FormError>{errors.initials.message}</FormError>}
<FormLabel htmlFor="password">
Password
<FormTextInput
type="password"
id="password"
name="password"
ref={register({ required: 'Password is required' })}
/>
<FormIcon>
<Lock width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.password && <FormError>{errors.password.message}</FormError>}
<FormLabel htmlFor="password_confirm">
Password (Confirm)
<FormTextInput
type="password"
id="password_confirm"
name="password_confirm"
ref={register({ required: 'Password (confirm) is required' })}
/>
<FormIcon>
<Lock width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.password_confirm && <FormError>{errors.password_confirm.message}</FormError>}
{registered ? (
<>
<Title>Thanks for registering</Title>
<SubTitle>Please check your inbox for a confirmation email.</SubTitle>
</>
) : (
<>
<Title>Register</Title>
<SubTitle>Please create your user</SubTitle>
<Form onSubmit={handleSubmit(loginSubmit)}>
<FormLabel htmlFor="fullname">
Full name
<FormTextInput
type="text"
id="fullname"
name="fullname"
ref={register({ required: 'Full name is required' })}
/>
<FormIcon>
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="username">
Username
<FormTextInput
type="text"
id="username"
name="username"
ref={register({ required: 'Username is required' })}
/>
<FormIcon>
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="email">
Email
<FormTextInput
type="text"
id="email"
name="email"
ref={register({
required: 'Email is required',
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
})}
/>
<FormIcon>
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.email && <FormError>{errors.email.message}</FormError>}
<FormLabel htmlFor="initials">
Initials
<FormTextInput
type="text"
id="initials"
name="initials"
ref={register({
required: 'Initials is required',
pattern: {
value: INITIALS_PATTERN,
message: 'Initials must be between 2 to 3 characters.',
},
})}
/>
<FormIcon>
<User width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.initials && <FormError>{errors.initials.message}</FormError>}
<FormLabel htmlFor="password">
Password
<FormTextInput
type="password"
id="password"
name="password"
ref={register({ required: 'Password is required' })}
/>
<FormIcon>
<Lock width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.password && <FormError>{errors.password.message}</FormError>}
<FormLabel htmlFor="password_confirm">
Password (Confirm)
<FormTextInput
type="password"
id="password_confirm"
name="password_confirm"
ref={register({ required: 'Password (confirm) is required' })}
/>
<FormIcon>
<Lock width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.password_confirm && <FormError>{errors.password_confirm.message}</FormError>}
<ActionButtons>
<RegisterButton type="submit" disabled={!isComplete}>
Register
</RegisterButton>
</ActionButtons>
</Form>
<ActionButtons>
<RegisterButton type="submit" disabled={!isComplete}>
Register
</RegisterButton>
</ActionButtons>
</Form>
</>
)}
</LoginFormContainer>
</LoginFormWrapper>
</Column>

View File

@ -16,7 +16,7 @@ function getBackgroundColor(isDisabled: boolean, isSelected: boolean, isFocused:
return null;
}
const colourStyles = {
export const colourStyles = {
control: (styles: any, data: any) => {
return {
...styles,

View File

@ -1,5 +1,5 @@
import React, { useRef } from 'react';
import styled from 'styled-components';
import styled, { css } from 'styled-components';
import { DoubleChevronUp, Crown } from 'shared/icons';
export const AdminIcon = styled(DoubleChevronUp)`
@ -24,7 +24,12 @@ const TaskDetailAssignee = styled.div`
position: relative;
`;
export const Wrapper = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>`
export const Wrapper = styled.div<{
size: number | string;
bgColor: string | null;
backgroundURL: string | null;
hasClick: boolean;
}>`
width: ${props => props.size}px;
height: ${props => props.size}px;
border-radius: 9999px;
@ -37,33 +42,60 @@ export const Wrapper = styled.div<{ size: number | string; bgColor: string | nul
background-size: contain;
font-size: 14px;
font-weight: 400;
&:hover {
opacity: 0.8;
}
${props =>
props.hasClick &&
css`
&:hover {
opacity: 0.8;
}
`}
`;
type TaskAssigneeProps = {
size: number | string;
showRoleIcons?: boolean;
member: TaskUser;
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
invited?: boolean;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
className?: string;
};
const TaskAssignee: React.FC<TaskAssigneeProps> = ({ showRoleIcons, member, onMemberProfile, size, className }) => {
const TaskAssignee: React.FC<TaskAssigneeProps> = ({
showRoleIcons,
member,
invited,
onMemberProfile,
size,
className,
}) => {
const $memberRef = useRef<HTMLDivElement>(null);
let profileIcon: ProfileIcon = {
url: null,
bgColor: null,
initials: null,
};
if (member.profileIcon) {
profileIcon = member.profileIcon;
}
return (
<TaskDetailAssignee
className={className}
ref={$memberRef}
onClick={e => {
e.stopPropagation();
onMemberProfile($memberRef, member.id);
if (onMemberProfile) {
onMemberProfile($memberRef, member.id);
}
}}
key={member.id}
>
<Wrapper backgroundURL={member.profileIcon.url ?? null} bgColor={member.profileIcon.bgColor ?? null} size={size}>
{(!member.profileIcon.url && member.profileIcon.initials) ?? ''}
<Wrapper
hasClick={typeof onMemberProfile !== undefined}
backgroundURL={profileIcon.url ?? null}
bgColor={profileIcon.bgColor ?? null}
size={size}
>
{(!profileIcon.url && profileIcon.initials) ?? ''}
</Wrapper>
{showRoleIcons && member.role && member.role.code === 'admin' && <AdminIcon width={10} height={10} />}
{showRoleIcons && member.role && member.role.code === 'owner' && <OwnerIcon width={10} height={10} />}

View File

@ -1,10 +1,11 @@
import React, { useRef, useState, useEffect } from 'react';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle } from 'shared/icons';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle, ListUnordered } from 'shared/icons';
import styled from 'styled-components';
import ProfileIcon from 'shared/components/ProfileIcon';
import { usePopup } from 'shared/components/PopupMenu';
import { RoleCode } from 'shared/generated/graphql';
import NOOP from 'shared/utils/noop';
import { useHistory } from 'react-router';
import {
TaskcafeLogo,
TaskcafeTitle,
@ -173,8 +174,11 @@ type NavBarProps = {
user: TaskUser | null;
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
projectMembers?: Array<TaskUser> | null;
projectInvitedMembers?: Array<InvitedUser> | null;
onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
};
const NavBar: React.FC<NavBarProps> = ({
@ -184,10 +188,12 @@ const NavBar: React.FC<NavBarProps> = ({
onChangeProjectOwner,
currentTab,
onMemberProfile,
onInvitedMemberProfile,
canEditProjectName = false,
onOpenProjectFinder,
onFavorite,
onSetTab,
projectInvitedMembers,
onChangeRole,
name,
onRemoveFromBoard,
@ -204,6 +210,7 @@ const NavBar: React.FC<NavBarProps> = ({
onProfileClick($target);
}
};
const history = useHistory();
const { showPopup } = usePopup();
return (
<NavbarWrapper>
@ -245,19 +252,38 @@ const NavBar: React.FC<NavBarProps> = ({
<TaskcafeTitle>Taskcafé</TaskcafeTitle>
</LogoContainer>
<GlobalActions>
{projectMembers && onMemberProfile && (
{projectMembers && projectInvitedMembers && onMemberProfile && onInvitedMemberProfile && (
<>
<ProjectMembers>
{projectMembers.map((member, idx) => (
<ProjectMember
showRoleIcons
zIndex={projectMembers.length - idx}
zIndex={projectMembers.length - idx + projectInvitedMembers.length}
key={member.id}
size={28}
member={member}
onMemberProfile={onMemberProfile}
/>
))}
{projectInvitedMembers.map((member, idx) => (
<ProjectMember
showRoleIcons
zIndex={projectInvitedMembers.length - idx}
key={member.email}
size={28}
invited
member={{
id: member.email,
fullName: member.email,
profileIcon: {
url: null,
initials: member.email.charAt(0),
bgColor: '#fff',
},
}}
onMemberProfile={onInvitedMemberProfile}
/>
))}
{canInviteUser && (
<InviteButton
onClick={$target => {
@ -283,6 +309,9 @@ const NavBar: React.FC<NavBarProps> = ({
<IconContainer disabled onClick={NOOP}>
<CheckCircle width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={NOOP}>
<ListUnordered width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={onNotificationClick}>
<Bell color="#c2c6dc" size={20} />
</IconContainer>

View File

@ -113,6 +113,14 @@ export type UserAccount = {
member: MemberList;
};
export type InvitedUserAccount = {
__typename?: 'InvitedUserAccount';
id: Scalars['ID'];
email: Scalars['String'];
invitedOn: Scalars['Time'];
member: MemberList;
};
export type Team = {
__typename?: 'Team';
id: Scalars['ID'];
@ -121,6 +129,12 @@ export type Team = {
members: Array<Member>;
};
export type InvitedMember = {
__typename?: 'InvitedMember';
email: Scalars['String'];
invitedOn: Scalars['Time'];
};
export type Project = {
__typename?: 'Project';
id: Scalars['ID'];
@ -129,6 +143,7 @@ export type Project = {
team?: Maybe<Team>;
taskGroups: Array<TaskGroup>;
members: Array<Member>;
invitedMembers: Array<InvitedMember>;
labels: Array<ProjectLabel>;
};
@ -221,11 +236,13 @@ export type Query = {
findTask: Task;
findTeam: Team;
findUser: UserAccount;
invitedUsers: Array<InvitedUserAccount>;
labelColors: Array<LabelColor>;
me: MePayload;
notifications: Array<Notification>;
organizations: Array<Organization>;
projects: Array<Project>;
searchMembers: Array<MemberSearchResult>;
taskGroups: Array<TaskGroup>;
teams: Array<Team>;
users: Array<UserAccount>;
@ -256,6 +273,11 @@ export type QueryProjectsArgs = {
input?: Maybe<ProjectsFilter>;
};
export type QuerySearchMembersArgs = {
input: MemberSearchFilter;
};
export type Mutation = {
__typename?: 'Mutation';
addTaskLabel: Task;
@ -263,7 +285,6 @@ export type Mutation = {
clearProfileAvatar: UserAccount;
createProject: Project;
createProjectLabel: ProjectLabel;
createProjectMember: CreateProjectMemberPayload;
createRefreshToken: RefreshToken;
createTask: Task;
createTaskChecklist: TaskChecklist;
@ -272,6 +293,8 @@ export type Mutation = {
createTeam: Team;
createTeamMember: CreateTeamMemberPayload;
createUserAccount: UserAccount;
deleteInvitedProjectMember: DeleteInvitedProjectMemberPayload;
deleteInvitedUserAccount: DeleteInvitedUserAccountPayload;
deleteProject: DeleteProjectPayload;
deleteProjectLabel: ProjectLabel;
deleteProjectMember: DeleteProjectMemberPayload;
@ -284,6 +307,7 @@ export type Mutation = {
deleteTeamMember: DeleteTeamMemberPayload;
deleteUserAccount: DeleteUserAccountPayload;
duplicateTaskGroup: DuplicateTaskGroupPayload;
inviteProjectMembers: InviteProjectMembersPayload;
logoutUser: Scalars['Boolean'];
removeTaskLabel: Task;
setTaskChecklistItemComplete: TaskChecklistItem;
@ -333,11 +357,6 @@ export type MutationCreateProjectLabelArgs = {
};
export type MutationCreateProjectMemberArgs = {
input: CreateProjectMember;
};
export type MutationCreateRefreshTokenArgs = {
input: NewRefreshToken;
};
@ -378,6 +397,16 @@ export type MutationCreateUserAccountArgs = {
};
export type MutationDeleteInvitedProjectMemberArgs = {
input: DeleteInvitedProjectMember;
};
export type MutationDeleteInvitedUserAccountArgs = {
input: DeleteInvitedUserAccount;
};
export type MutationDeleteProjectArgs = {
input: DeleteProject;
};
@ -438,6 +467,11 @@ export type MutationDuplicateTaskGroupArgs = {
};
export type MutationInviteProjectMembersArgs = {
input: InviteProjectMembers;
};
export type MutationLogoutUserArgs = {
input: LogoutUser;
};
@ -688,15 +722,32 @@ export type UpdateProjectLabelColor = {
labelColorID: Scalars['UUID'];
};
export type CreateProjectMember = {
export type DeleteInvitedProjectMember = {
projectID: Scalars['UUID'];
userID: Scalars['UUID'];
email: Scalars['String'];
};
export type CreateProjectMemberPayload = {
__typename?: 'CreateProjectMemberPayload';
export type DeleteInvitedProjectMemberPayload = {
__typename?: 'DeleteInvitedProjectMemberPayload';
invitedMember: InvitedMember;
};
export type MemberInvite = {
userID?: Maybe<Scalars['UUID']>;
email?: Maybe<Scalars['String']>;
};
export type InviteProjectMembers = {
projectID: Scalars['UUID'];
members: Array<MemberInvite>;
};
export type InviteProjectMembersPayload = {
__typename?: 'InviteProjectMembersPayload';
ok: Scalars['Boolean'];
member: Member;
projectID: Scalars['UUID'];
members: Array<Member>;
invitedMembers: Array<InvitedMember>;
};
export type DeleteProjectMember = {
@ -989,6 +1040,29 @@ export type UpdateTeamMemberRolePayload = {
member: Member;
};
export type DeleteInvitedUserAccount = {
invitedUserID: Scalars['UUID'];
};
export type DeleteInvitedUserAccountPayload = {
__typename?: 'DeleteInvitedUserAccountPayload';
invitedUser: InvitedUserAccount;
};
export type MemberSearchFilter = {
SearchFilter: Scalars['String'];
projectID?: Maybe<Scalars['UUID']>;
};
export type MemberSearchResult = {
__typename?: 'MemberSearchResult';
similarity: Scalars['Int'];
user: UserAccount;
confirmed: Scalars['Boolean'];
invited: Scalars['Boolean'];
joined: Scalars['Boolean'];
};
export type UpdateUserInfoPayload = {
__typename?: 'UpdateUserInfoPayload';
user: UserAccount;
@ -1205,6 +1279,9 @@ export type FindProjectQuery = (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
) }
)>, invitedMembers: Array<(
{ __typename?: 'InvitedMember' }
& Pick<InvitedMember, 'email' | 'invitedOn'>
)>, labels: Array<(
{ __typename?: 'ProjectLabel' }
& Pick<ProjectLabel, 'id' | 'createdDate' | 'name'>
@ -1390,31 +1467,6 @@ export type MeQuery = (
) }
);
export type CreateProjectMemberMutationVariables = {
projectID: Scalars['UUID'];
userID: Scalars['UUID'];
};
export type CreateProjectMemberMutation = (
{ __typename?: 'Mutation' }
& { createProjectMember: (
{ __typename?: 'CreateProjectMemberPayload' }
& Pick<CreateProjectMemberPayload, 'ok'>
& { member: (
{ __typename?: 'Member' }
& Pick<Member, 'id' | 'fullName' | 'username'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
), role: (
{ __typename?: 'Role' }
& Pick<Role, 'code' | 'name'>
) }
) }
) }
);
export type DeleteProjectMutationVariables = {
projectID: Scalars['UUID'];
};
@ -1432,6 +1484,23 @@ export type DeleteProjectMutation = (
) }
);
export type DeleteInvitedProjectMemberMutationVariables = {
projectID: Scalars['UUID'];
email: Scalars['String'];
};
export type DeleteInvitedProjectMemberMutation = (
{ __typename?: 'Mutation' }
& { deleteInvitedProjectMember: (
{ __typename?: 'DeleteInvitedProjectMemberPayload' }
& { invitedMember: (
{ __typename?: 'InvitedMember' }
& Pick<InvitedMember, 'email'>
) }
) }
);
export type DeleteProjectMemberMutationVariables = {
projectID: Scalars['UUID'];
userID: Scalars['UUID'];
@ -1450,6 +1519,34 @@ export type DeleteProjectMemberMutation = (
) }
);
export type InviteProjectMembersMutationVariables = {
projectID: Scalars['UUID'];
members: Array<MemberInvite>;
};
export type InviteProjectMembersMutation = (
{ __typename?: 'Mutation' }
& { inviteProjectMembers: (
{ __typename?: 'InviteProjectMembersPayload' }
& Pick<InviteProjectMembersPayload, 'ok'>
& { invitedMembers: Array<(
{ __typename?: 'InvitedMember' }
& Pick<InvitedMember, 'email' | 'invitedOn'>
)>, members: Array<(
{ __typename?: 'Member' }
& Pick<Member, 'id' | 'fullName' | 'username'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
), role: (
{ __typename?: 'Role' }
& Pick<Role, 'code' | 'name'>
) }
)> }
) }
);
export type UpdateProjectMemberRoleMutationVariables = {
projectID: Scalars['UUID'];
userID: Scalars['UUID'];
@ -2130,6 +2227,22 @@ export type CreateUserAccountMutation = (
) }
);
export type DeleteInvitedUserAccountMutationVariables = {
invitedUserID: Scalars['UUID'];
};
export type DeleteInvitedUserAccountMutation = (
{ __typename?: 'Mutation' }
& { deleteInvitedUserAccount: (
{ __typename?: 'DeleteInvitedUserAccountPayload' }
& { invitedUser: (
{ __typename?: 'InvitedUserAccount' }
& Pick<InvitedUserAccount, 'id'>
) }
) }
);
export type DeleteUserAccountMutationVariables = {
userID: Scalars['UUID'];
newOwnerID?: Maybe<Scalars['UUID']>;
@ -2211,7 +2324,10 @@ export type UsersQueryVariables = {};
export type UsersQuery = (
{ __typename?: 'Query' }
& { users: Array<(
& { invitedUsers: Array<(
{ __typename?: 'InvitedUserAccount' }
& Pick<InvitedUserAccount, 'id' | 'email' | 'invitedOn'>
)>, users: Array<(
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'username'>
& { role: (
@ -2603,6 +2719,10 @@ export const FindProjectDocument = gql`
bgColor
}
}
invitedMembers {
email
invitedOn
}
labels {
id
createdDate
@ -2884,53 +3004,6 @@ export function useMeLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptio
export type MeQueryHookResult = ReturnType<typeof useMeQuery>;
export type MeLazyQueryHookResult = ReturnType<typeof useMeLazyQuery>;
export type MeQueryResult = ApolloReactCommon.QueryResult<MeQuery, MeQueryVariables>;
export const CreateProjectMemberDocument = gql`
mutation createProjectMember($projectID: UUID!, $userID: UUID!) {
createProjectMember(input: {projectID: $projectID, userID: $userID}) {
ok
member {
id
fullName
profileIcon {
url
initials
bgColor
}
username
role {
code
name
}
}
}
}
`;
export type CreateProjectMemberMutationFn = ApolloReactCommon.MutationFunction<CreateProjectMemberMutation, CreateProjectMemberMutationVariables>;
/**
* __useCreateProjectMemberMutation__
*
* To run a mutation, you first call `useCreateProjectMemberMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateProjectMemberMutation` 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 [createProjectMemberMutation, { data, loading, error }] = useCreateProjectMemberMutation({
* variables: {
* projectID: // value for 'projectID'
* userID: // value for 'userID'
* },
* });
*/
export function useCreateProjectMemberMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<CreateProjectMemberMutation, CreateProjectMemberMutationVariables>) {
return ApolloReactHooks.useMutation<CreateProjectMemberMutation, CreateProjectMemberMutationVariables>(CreateProjectMemberDocument, baseOptions);
}
export type CreateProjectMemberMutationHookResult = ReturnType<typeof useCreateProjectMemberMutation>;
export type CreateProjectMemberMutationResult = ApolloReactCommon.MutationResult<CreateProjectMemberMutation>;
export type CreateProjectMemberMutationOptions = ApolloReactCommon.BaseMutationOptions<CreateProjectMemberMutation, CreateProjectMemberMutationVariables>;
export const DeleteProjectDocument = gql`
mutation deleteProject($projectID: UUID!) {
deleteProject(input: {projectID: $projectID}) {
@ -2966,6 +3039,41 @@ export function useDeleteProjectMutation(baseOptions?: ApolloReactHooks.Mutation
export type DeleteProjectMutationHookResult = ReturnType<typeof useDeleteProjectMutation>;
export type DeleteProjectMutationResult = ApolloReactCommon.MutationResult<DeleteProjectMutation>;
export type DeleteProjectMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteProjectMutation, DeleteProjectMutationVariables>;
export const DeleteInvitedProjectMemberDocument = gql`
mutation deleteInvitedProjectMember($projectID: UUID!, $email: String!) {
deleteInvitedProjectMember(input: {projectID: $projectID, email: $email}) {
invitedMember {
email
}
}
}
`;
export type DeleteInvitedProjectMemberMutationFn = ApolloReactCommon.MutationFunction<DeleteInvitedProjectMemberMutation, DeleteInvitedProjectMemberMutationVariables>;
/**
* __useDeleteInvitedProjectMemberMutation__
*
* To run a mutation, you first call `useDeleteInvitedProjectMemberMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteInvitedProjectMemberMutation` 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 [deleteInvitedProjectMemberMutation, { data, loading, error }] = useDeleteInvitedProjectMemberMutation({
* variables: {
* projectID: // value for 'projectID'
* email: // value for 'email'
* },
* });
*/
export function useDeleteInvitedProjectMemberMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteInvitedProjectMemberMutation, DeleteInvitedProjectMemberMutationVariables>) {
return ApolloReactHooks.useMutation<DeleteInvitedProjectMemberMutation, DeleteInvitedProjectMemberMutationVariables>(DeleteInvitedProjectMemberDocument, baseOptions);
}
export type DeleteInvitedProjectMemberMutationHookResult = ReturnType<typeof useDeleteInvitedProjectMemberMutation>;
export type DeleteInvitedProjectMemberMutationResult = ApolloReactCommon.MutationResult<DeleteInvitedProjectMemberMutation>;
export type DeleteInvitedProjectMemberMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteInvitedProjectMemberMutation, DeleteInvitedProjectMemberMutationVariables>;
export const DeleteProjectMemberDocument = gql`
mutation deleteProjectMember($projectID: UUID!, $userID: UUID!) {
deleteProjectMember(input: {projectID: $projectID, userID: $userID}) {
@ -3003,6 +3111,57 @@ export function useDeleteProjectMemberMutation(baseOptions?: ApolloReactHooks.Mu
export type DeleteProjectMemberMutationHookResult = ReturnType<typeof useDeleteProjectMemberMutation>;
export type DeleteProjectMemberMutationResult = ApolloReactCommon.MutationResult<DeleteProjectMemberMutation>;
export type DeleteProjectMemberMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteProjectMemberMutation, DeleteProjectMemberMutationVariables>;
export const InviteProjectMembersDocument = gql`
mutation inviteProjectMembers($projectID: UUID!, $members: [MemberInvite!]!) {
inviteProjectMembers(input: {projectID: $projectID, members: $members}) {
ok
invitedMembers {
email
invitedOn
}
members {
id
fullName
profileIcon {
url
initials
bgColor
}
username
role {
code
name
}
}
}
}
`;
export type InviteProjectMembersMutationFn = ApolloReactCommon.MutationFunction<InviteProjectMembersMutation, InviteProjectMembersMutationVariables>;
/**
* __useInviteProjectMembersMutation__
*
* To run a mutation, you first call `useInviteProjectMembersMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useInviteProjectMembersMutation` 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 [inviteProjectMembersMutation, { data, loading, error }] = useInviteProjectMembersMutation({
* variables: {
* projectID: // value for 'projectID'
* members: // value for 'members'
* },
* });
*/
export function useInviteProjectMembersMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<InviteProjectMembersMutation, InviteProjectMembersMutationVariables>) {
return ApolloReactHooks.useMutation<InviteProjectMembersMutation, InviteProjectMembersMutationVariables>(InviteProjectMembersDocument, baseOptions);
}
export type InviteProjectMembersMutationHookResult = ReturnType<typeof useInviteProjectMembersMutation>;
export type InviteProjectMembersMutationResult = ApolloReactCommon.MutationResult<InviteProjectMembersMutation>;
export type InviteProjectMembersMutationOptions = ApolloReactCommon.BaseMutationOptions<InviteProjectMembersMutation, InviteProjectMembersMutationVariables>;
export const UpdateProjectMemberRoleDocument = gql`
mutation updateProjectMemberRole($projectID: UUID!, $userID: UUID!, $roleCode: RoleCode!) {
updateProjectMemberRole(input: {projectID: $projectID, userID: $userID, roleCode: $roleCode}) {
@ -4381,6 +4540,40 @@ export function useCreateUserAccountMutation(baseOptions?: ApolloReactHooks.Muta
export type CreateUserAccountMutationHookResult = ReturnType<typeof useCreateUserAccountMutation>;
export type CreateUserAccountMutationResult = ApolloReactCommon.MutationResult<CreateUserAccountMutation>;
export type CreateUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<CreateUserAccountMutation, CreateUserAccountMutationVariables>;
export const DeleteInvitedUserAccountDocument = gql`
mutation deleteInvitedUserAccount($invitedUserID: UUID!) {
deleteInvitedUserAccount(input: {invitedUserID: $invitedUserID}) {
invitedUser {
id
}
}
}
`;
export type DeleteInvitedUserAccountMutationFn = ApolloReactCommon.MutationFunction<DeleteInvitedUserAccountMutation, DeleteInvitedUserAccountMutationVariables>;
/**
* __useDeleteInvitedUserAccountMutation__
*
* To run a mutation, you first call `useDeleteInvitedUserAccountMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteInvitedUserAccountMutation` 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 [deleteInvitedUserAccountMutation, { data, loading, error }] = useDeleteInvitedUserAccountMutation({
* variables: {
* invitedUserID: // value for 'invitedUserID'
* },
* });
*/
export function useDeleteInvitedUserAccountMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteInvitedUserAccountMutation, DeleteInvitedUserAccountMutationVariables>) {
return ApolloReactHooks.useMutation<DeleteInvitedUserAccountMutation, DeleteInvitedUserAccountMutationVariables>(DeleteInvitedUserAccountDocument, baseOptions);
}
export type DeleteInvitedUserAccountMutationHookResult = ReturnType<typeof useDeleteInvitedUserAccountMutation>;
export type DeleteInvitedUserAccountMutationResult = ApolloReactCommon.MutationResult<DeleteInvitedUserAccountMutation>;
export type DeleteInvitedUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteInvitedUserAccountMutation, DeleteInvitedUserAccountMutationVariables>;
export const DeleteUserAccountDocument = gql`
mutation deleteUserAccount($userID: UUID!, $newOwnerID: UUID) {
deleteUserAccount(input: {userID: $userID, newOwnerID: $newOwnerID}) {
@ -4534,6 +4727,11 @@ export type UpdateUserRoleMutationResult = ApolloReactCommon.MutationResult<Upda
export type UpdateUserRoleMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateUserRoleMutation, UpdateUserRoleMutationVariables>;
export const UsersDocument = gql`
query users {
invitedUsers {
id
email
invitedOn
}
users {
id
email
@ -4595,4 +4793,4 @@ export function useUsersLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOp
}
export type UsersQueryHookResult = ReturnType<typeof useUsersQuery>;
export type UsersLazyQueryHookResult = ReturnType<typeof useUsersLazyQuery>;
export type UsersQueryResult = ApolloReactCommon.QueryResult<UsersQuery, UsersQueryVariables>;
export type UsersQueryResult = ApolloReactCommon.QueryResult<UsersQuery, UsersQueryVariables>;

View File

@ -22,6 +22,10 @@ query findProject($projectID: UUID!) {
bgColor
}
}
invitedMembers {
email
invitedOn
}
labels {
id
createdDate

View File

@ -1,25 +0,0 @@
import gql from 'graphql-tag';
export const CREATE_PROJECT_MEMBER_MUTATION = gql`
mutation createProjectMember($projectID: UUID!, $userID: UUID!) {
createProjectMember(input: { projectID: $projectID, userID: $userID }) {
ok
member {
id
fullName
profileIcon {
url
initials
bgColor
}
username
role {
code
name
}
}
}
}
`;
export default CREATE_PROJECT_MEMBER_MUTATION;

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
export const DELETE_PROJECT_INVITED_MEMBER_MUTATION = gql`
mutation deleteInvitedProjectMember($projectID: UUID!, $email: String!) {
deleteInvitedProjectMember(input: { projectID: $projectID, email: $email }) {
invitedMember {
email
}
}
}
`;
export default DELETE_PROJECT_INVITED_MEMBER_MUTATION;

View File

@ -0,0 +1,29 @@
import gql from 'graphql-tag';
export const INVITE_PROJECT_MEMBERS_MUTATION = gql`
mutation inviteProjectMembers($projectID: UUID!, $members: [MemberInvite!]!) {
inviteProjectMembers(input: { projectID: $projectID, members: $members }) {
ok
invitedMembers {
email
invitedOn
}
members {
id
fullName
profileIcon {
url
initials
bgColor
}
username
role {
code
name
}
}
}
}
`;
export default INVITE_PROJECT_MEMBERS_MUTATION;

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
export const DELETE_INVITED_USER_MUTATION = gql`
mutation deleteInvitedUserAccount($invitedUserID: UUID!) {
deleteInvitedUserAccount(input: { invitedUserID: $invitedUserID }) {
invitedUser {
id
}
}
}
`;
export default DELETE_INVITED_USER_MUTATION;

View File

@ -1,4 +1,9 @@
query users {
invitedUsers {
id
email
invitedOn
}
users {
id
email

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const ArrowDown: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 448 512">
<path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z" />
</Icon>
);
};
export default ArrowDown;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const CaretDown: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 320 512">
<path d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z" />
</Icon>
);
};
export default CaretDown;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const CaretRight: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 192 512">
<path d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z" />
</Icon>
);
};
export default CaretRight;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Dot: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 18 18">
<circle cx="9" cy="9" r="3.5" />
</Icon>
);
};
export default Dot;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const DotCircle: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm80 248c0 44.112-35.888 80-80 80s-80-35.888-80-80 35.888-80 80-80 80 35.888 80 80z" />
</Icon>
);
};
export default DotCircle;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const EyeSlash: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 640 512">
<path d="M320 400c-75.85 0-137.25-58.71-142.9-133.11L72.2 185.82c-13.79 17.3-26.48 35.59-36.72 55.59a32.35 32.35 0 0 0 0 29.19C89.71 376.41 197.07 448 320 448c26.91 0 52.87-4 77.89-10.46L346 397.39a144.13 144.13 0 0 1-26 2.61zm313.82 58.1l-110.55-85.44a331.25 331.25 0 0 0 81.25-102.07 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64a308.15 308.15 0 0 0-147.32 37.7L45.46 3.37A16 16 0 0 0 23 6.18L3.37 31.45A16 16 0 0 0 6.18 53.9l588.36 454.73a16 16 0 0 0 22.46-2.81l19.64-25.27a16 16 0 0 0-2.82-22.45zm-183.72-142l-39.3-30.38A94.75 94.75 0 0 0 416 256a94.76 94.76 0 0 0-121.31-92.21A47.65 47.65 0 0 1 304 192a46.64 46.64 0 0 1-1.54 10l-73.61-56.89A142.31 142.31 0 0 1 320 112a143.92 143.92 0 0 1 144 144c0 21.63-5.29 41.79-13.9 60.11z" />
</Icon>
);
};
export default EyeSlash;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const ListUnordered: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M48 48a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0 160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0 160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm448 16H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-320H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z" />
</Icon>
);
};
export default ListUnordered;

View File

@ -1,9 +1,16 @@
import Cross from './Cross';
import Cog from './Cog';
import ArrowDown from './ArrowDown';
import ListUnordered from './ListUnordered';
import Dot from './Dot';
import CaretDown from './CaretDown';
import Eye from './Eye';
import EyeSlash from './EyeSlash';
import CaretRight from './CaretRight';
import List from './List';
import At from './At';
import Task from './Task';
import DotCircle from './DotCircle';
import Smile from './Smile';
import Paperclip from './Paperclip';
import Calendar from './Calendar';
@ -88,5 +95,12 @@ export {
Paperclip,
Share,
Eye,
ListUnordered,
EyeSlash,
List,
CaretDown,
Dot,
ArrowDown,
CaretRight,
DotCircle,
};

View File

@ -61,7 +61,7 @@ type User = TaskUser & {
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
setup?: null | { confirmToken: string };
};
type LoginFormData = {
@ -91,7 +91,14 @@ type ErrorOption =
type: string;
};
type SetFailedFn = () => void;
type ConfirmProps = {
hasConfirmToken: boolean;
onConfirmUser: (setFailed: SetFailedFn) => void;
};
type RegisterProps = {
registered?: boolean;
onSubmit: (
data: RegisterFormData,
setComplete: (val: boolean) => void,
@ -127,3 +134,141 @@ type ElementBounds = {
};
type CardLabelVariant = 'large' | 'small';
type InvitedUser = {
email: string;
invitedOn: string;
};
type InvitedUserAccount = {
id: string;
email: string;
invitedOn: string;
};
type NodeDimensions = {
entry: React.RefObject<HTMLElement>;
children: React.RefObject<HTMLElement> | null;
};
type OutlineNode = {
id: string;
parent: string;
depth: number;
position: number;
ancestors: Array<string>;
collapsed: boolean;
children: number;
};
type RelationshipChild = {
position: number;
id: string;
depth: number;
children: number;
};
type NodeRelationships = {
self: { id: string; depth: number };
children: Array<RelationshipChild>;
numberOfSubChildren: number;
};
type OutlineData = {
published: Map<string, string>;
nodes: Map<number, Map<string, OutlineNode>>;
relationships: Map<string, NodeRelationships>;
dimensions: Map<string, NodeDimensions>;
};
type ImpactZoneData = {
node: OutlineNode;
dimensions: NodeDimensions;
};
type ImpactZone = {
above: ImpactZoneData | null;
below: ImpactZoneData | null;
};
type ImpactData = {
zone: ImpactZone;
depth: number;
};
type ImpactPosition = 'before' | 'after' | 'beforeChildren' | 'afterChildren';
type ImpactAction = {
on: 'children' | 'entry';
position: ImpactPosition;
};
type ItemElement = {
id: string;
parent: null | string;
position: number;
collapsed: boolean;
children?: Array<ItemElement>;
};
type NodeDimensions = {
entry: React.RefObject<HTMLElement>;
children: React.RefObject<HTMLElement> | null;
};
type OutlineNode = {
id: string;
parent: string;
depth: number;
position: number;
ancestors: Array<string>;
children: number;
};
type RelationshipChild = {
position: number;
id: string;
depth: number;
children: number;
};
type NodeRelationships = {
self: { id: string; depth: number };
children: Array<RelationshipChild>;
numberOfSubChildren: number;
};
type OutlineData = {
published: Map<string, string>;
nodes: Map<number, Map<string, OutlineNode>>;
relationships: Map<string, NodeRelationships>;
dimensions: Map<string, NodeDimensions>;
};
type ImpactZoneData = {
node: OutlineNode;
dimensions: NodeDimensions;
};
type ImpactZone = {
above: ImpactZoneData | null;
below: ImpactZoneData | null;
};
type ImpactData = {
zone: ImpactZone;
depth: number;
};
type ImpactPosition = 'before' | 'after' | 'beforeChildren' | 'afterChildren';
type ImpactAction = {
on: 'children' | 'entry';
position: ImpactPosition;
};
type ItemElement = {
id: string;
parent: null | string;
position: number;
children?: Array<ItemElement>;
};

View File

@ -3112,6 +3112,13 @@
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw==
"@types/query-string@^6.3.0":
version "6.3.0"
resolved "https://registry.yarnpkg.com/@types/query-string/-/query-string-6.3.0.tgz#b6fa172a01405abcaedac681118e78429d62ea39"
integrity sha512-yuIv/WRffRzL7cBW+sla4HwBZrEXRNf1MKQ5SklPEadth+BKbDxiVG8A3iISN5B3yC4EeSCzMZP8llHTcUhOzQ==
dependencies:
query-string "*"
"@types/reach__router@^1.2.3":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@types/reach__router/-/reach__router-1.3.0.tgz#4c05a947ccecca05c72bb335a0f7bb43fec12446"
@ -10600,6 +10607,11 @@ lodash@4.17.15, "lodash@>=3.5 <5", lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17.
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
lodash@^4.17.20:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
log-symbols@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4"
@ -13351,6 +13363,15 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
query-string@*, query-string@^6.13.7:
version "6.13.7"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.7.tgz#af53802ff6ed56f3345f92d40a056f93681026ee"
integrity sha512-CsGs8ZYb39zu0WLkeOhe0NMePqgYdAuCqxOYKDR5LVCytDZYMGx3Bb+xypvQvPHVPijRXB0HZNFllCzHRe4gEA==
dependencies:
decode-uri-component "^0.2.0"
split-on-first "^1.0.0"
strict-uri-encode "^2.0.0"
query-string@^4.1.0:
version "4.3.4"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"
@ -15249,6 +15270,11 @@ speedometer@~1.0.0:
resolved "https://registry.yarnpkg.com/speedometer/-/speedometer-1.0.0.tgz#cd671cb06752c22bca3370e2f334440be4fc62e2"
integrity sha1-zWccsGdSwivKM3Di8zREC+T8YuI=
split-on-first@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@ -15402,6 +15428,11 @@ strict-uri-encode@^1.0.0:
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
strict-uri-encode@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY=
string-length@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"

3
go.mod
View File

@ -11,7 +11,9 @@ require (
github.com/google/uuid v1.1.1
github.com/jmoiron/sqlx v1.2.0
github.com/lib/pq v1.3.0
github.com/lithammer/fuzzysearch v1.1.0
github.com/magefile/mage v1.9.0
github.com/matcornic/hermes/v2 v2.1.0
github.com/pelletier/go-toml v1.8.0 // indirect
github.com/pkg/errors v0.9.1
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0
@ -21,4 +23,5 @@ require (
github.com/spf13/viper v1.4.0
github.com/vektah/gqlparser/v2 v2.0.1
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899
gopkg.in/mail.v2 v2.3.1
)

41
go.sum
View File

@ -50,11 +50,17 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym
github.com/ClickHouse/clickhouse-go v1.3.12/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI=
github.com/DataDog/sketches-go v0.0.0-20190923095040-43f19ad77ff7 h1:qELHH0AWCvf98Yf+CNIJx9vOZOfHFDDzgDRYsnNk/vs=
github.com/DataDog/sketches-go v0.0.0-20190923095040-43f19ad77ff7/go.mod h1:Q5DbzQ+3AkgGwymQO7aZFNP7ns2lZKGtvRBzRXfdi60=
github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc=
github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
github.com/Masterminds/sprig v2.16.0+incompatible h1:QZbMUPxRQ50EKAq3LFMnxddMu88/EUUG3qmxwtDmPsY=
github.com/Masterminds/sprig v2.16.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
github.com/RichardKnop/logging v0.0.0-20190827224416-1a693bdd4fae h1:DcFpTQBYQ9Ct2d6sC7ol0/ynxc2pO1cpGUM+f4t5adg=
github.com/RichardKnop/logging v0.0.0-20190827224416-1a693bdd4fae/go.mod h1:rJJ84PyA/Wlmw1hO+xTzV2wsSUon6J5ktg0g8BF2PuU=
github.com/RichardKnop/machinery v1.9.1 h1:Q4WInk0OWGMbXDH3Q8dm8uadN5Wcyquc+7IcM4p9ECs=
@ -70,6 +76,10 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/aokoli/goutils v1.0.1 h1:7fpzNGoJ3VA8qcrm++XEE1QUe0mIwNeLa02Nwq7RDkg=
github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
@ -109,6 +119,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSY
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -150,6 +161,7 @@ github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxm
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
@ -257,6 +269,7 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200507031123-427632fa3b1c/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
@ -264,6 +277,8 @@ github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
@ -290,7 +305,11 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0=
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
@ -317,6 +336,8 @@ github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc=
@ -357,6 +378,8 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lithammer/fuzzysearch v1.1.0 h1:go9v8tLCrNTTlH42OAaq4eHFe81TDHEnlrMEb6R4f+A=
github.com/lithammer/fuzzysearch v1.1.0/go.mod h1:Bqx4wo8lTOFcJr3ckpY6HA9lEIOO0H5HrkJ5CsN56HQ=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/magefile/mage v1.9.0 h1:t3AU2wNwehMCW97vuqQLtw6puppWXHO+O2MHo5a50XE=
github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
@ -365,6 +388,9 @@ github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/matcornic/hermes v1.2.0 h1:AuqZpYcTOtTB7cahdevLfnhIpfzmpqw5Czv8vpdnFDU=
github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc=
github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
@ -373,6 +399,8 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
@ -391,6 +419,8 @@ github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86w
github.com/neo4j-drivers/gobolt v1.7.4/go.mod h1:O9AUbip4Dgre+CD3p40dnMD4a4r52QBIfblg5k7CTbE=
github.com/neo4j/neo4j-go-driver v1.7.4/go.mod h1:aPO0vVr+WnhEJne+FgFjfsjzAnssPFLucHgGZ76Zb/U=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
@ -479,6 +509,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo=
github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -502,6 +534,10 @@ github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGr
github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 h1:L0rPdfzq43+NV8rfIx2kA4iSSLRj2jN5ijYHoeXRwvQ=
github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe h1:9YnI5plmy+ad6BM+JCLJb2ZV7/TNiE5l7SNKfumYKgc=
github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe/go.mod h1:JTFJA/t820uFDoyPpErFQ3rb3amdZoPtxcKervG0OE4=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
@ -540,6 +576,7 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
@ -591,6 +628,7 @@ golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -654,6 +692,7 @@ golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190225065934-cc5685c2db12/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -862,6 +901,8 @@ gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=

View File

@ -62,10 +62,7 @@ func initConfig() {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
panic(err)
}
}
// Execute the root cobra command
func Execute() {
viper.SetDefault("server.hostname", "0.0.0.0:3333")
viper.SetDefault("database.host", "127.0.0.1")
viper.SetDefault("database.name", "taskcafe")
@ -75,6 +72,20 @@ func Execute() {
viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/")
viper.SetDefault("queue.store", "memcache://localhost:11211")
}
// Execute the root cobra command
func Execute() {
viper.SetDefault("server.hostname", "0.0.0.0:3333")
viper.SetDefault("database.host", "127.0.0.1")
viper.SetDefault("database.name", "taskcafe")
viper.SetDefault("database.user", "taskcafe")
viper.SetDefault("database.password", "taskcafe_test")
viper.SetDefault("database.port", "5432")
viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/")
viper.SetDefault("queue.store", "memcache://localhost:11211")
rootCmd.SetVersionTemplate(versionTemplate)
rootCmd.AddCommand(newWebCmd(), newMigrateCmd(), newTokenCmd(), newWorkerCmd(), newResetPasswordCmd())
rootCmd.Execute()

View File

@ -34,11 +34,12 @@ func newMigrateCmd() *cobra.Command {
Short: "Run the database schema migrations",
Long: "Run the database schema migrations",
RunE: func(cmd *cobra.Command, args []string) error {
connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s sslmode=disable",
connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s port=%s sslmode=disable",
viper.GetString("database.user"),
viper.GetString("database.password"),
viper.GetString("database.host"),
viper.GetString("database.name"),
viper.GetString("database.port"),
)
db, err := sqlx.Connect("postgres", connection)
if err != nil {

View File

@ -32,11 +32,12 @@ func newWebCmd() *cobra.Command {
log.SetFormatter(Formatter)
log.SetLevel(log.InfoLevel)
connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s sslmode=disable",
connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s port=%s sslmode=disable",
viper.GetString("database.user"),
viper.GetString("database.password"),
viper.GetString("database.host"),
viper.GetString("database.name"),
viper.GetString("database.port"),
)
var db *sqlx.DB
var err error
@ -78,8 +79,11 @@ func newWebCmd() *cobra.Command {
return http.ListenAndServe(viper.GetString("server.hostname"), r)
},
}
cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server")
viper.BindPFlag("migrate", cc.Flags().Lookup("migrate"))
viper.SetDefault("migrate", false)
return cc
}

View File

@ -67,6 +67,12 @@ type ProjectMember struct {
RoleCode string `json:"role_code"`
}
type ProjectMemberInvited struct {
ProjectMemberInvitedID uuid.UUID `json:"project_member_invited_id"`
ProjectID uuid.UUID `json:"project_id"`
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
}
type RefreshToken struct {
TokenID uuid.UUID `json:"token_id"`
UserID uuid.UUID `json:"user_id"`
@ -164,4 +170,17 @@ type UserAccount struct {
ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"`
RoleCode string `json:"role_code"`
Bio string `json:"bio"`
Active bool `json:"active"`
}
type UserAccountConfirmToken struct {
ConfirmTokenID uuid.UUID `json:"confirm_token_id"`
Email string `json:"email"`
}
type UserAccountInvited struct {
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
Email string `json:"email"`
InvitedOn time.Time `json:"invited_on"`
HasJoined bool `json:"has_joined"`
}

View File

@ -99,6 +99,15 @@ func (q *Queries) CreateTeamProject(ctx context.Context, arg CreateTeamProjectPa
return i, err
}
const deleteInvitedProjectMemberByID = `-- name: DeleteInvitedProjectMemberByID :exec
DELETE FROM project_member_invited WHERE project_member_invited_id = $1
`
func (q *Queries) DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteInvitedProjectMemberByID, projectMemberInvitedID)
return err
}
const deleteProjectByID = `-- name: DeleteProjectByID :exec
DELETE FROM project WHERE project_id = $1
`
@ -122,12 +131,12 @@ func (q *Queries) DeleteProjectMember(ctx context.Context, arg DeleteProjectMemb
return err
}
const getAllProjects = `-- name: GetAllProjects :many
SELECT project_id, team_id, created_at, name FROM project
const getAllProjectsForTeam = `-- name: GetAllProjectsForTeam :many
SELECT project_id, team_id, created_at, name FROM project WHERE team_id = $1
`
func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getAllProjects)
func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getAllProjectsForTeam, teamID)
if err != nil {
return nil, err
}
@ -154,12 +163,12 @@ func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) {
return items, nil
}
const getAllProjectsForTeam = `-- name: GetAllProjectsForTeam :many
SELECT project_id, team_id, created_at, name FROM project WHERE team_id = $1
const getAllTeamProjects = `-- name: GetAllTeamProjects :many
SELECT project_id, team_id, created_at, name FROM project WHERE team_id IS NOT null
`
func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getAllProjectsForTeam, teamID)
func (q *Queries) GetAllTeamProjects(ctx context.Context) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getAllTeamProjects)
if err != nil {
return nil, err
}
@ -219,6 +228,42 @@ func (q *Queries) GetAllVisibleProjectsForUserID(ctx context.Context, userID uui
return items, nil
}
const getInvitedMembersForProjectID = `-- name: GetInvitedMembersForProjectID :many
SELECT uai.user_account_invited_id, email, invited_on FROM project_member_invited AS pmi
INNER JOIN user_account_invited AS uai
ON uai.user_account_invited_id = pmi.user_account_invited_id
WHERE project_id = $1
`
type GetInvitedMembersForProjectIDRow struct {
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
Email string `json:"email"`
InvitedOn time.Time `json:"invited_on"`
}
func (q *Queries) GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error) {
rows, err := q.db.QueryContext(ctx, getInvitedMembersForProjectID, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetInvitedMembersForProjectIDRow
for rows.Next() {
var i GetInvitedMembersForProjectIDRow
if err := rows.Scan(&i.UserAccountInvitedID, &i.Email, &i.InvitedOn); 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 getMemberProjectIDsForUserID = `-- name: GetMemberProjectIDsForUserID :many
SELECT project_id FROM project_member WHERE user_id = $1
`
@ -296,6 +341,26 @@ func (q *Queries) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Proj
return i, err
}
const getProjectMemberInvitedIDByEmail = `-- name: GetProjectMemberInvitedIDByEmail :one
SELECT email, invited_on, project_member_invited_id FROM user_account_invited AS uai
inner join project_member_invited AS pmi
ON pmi.user_account_invited_id = uai.user_account_invited_id
WHERE email = $1
`
type GetProjectMemberInvitedIDByEmailRow struct {
Email string `json:"email"`
InvitedOn time.Time `json:"invited_on"`
ProjectMemberInvitedID uuid.UUID `json:"project_member_invited_id"`
}
func (q *Queries) GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, error) {
row := q.db.QueryRowContext(ctx, getProjectMemberInvitedIDByEmail, email)
var i GetProjectMemberInvitedIDByEmailRow
err := row.Scan(&i.Email, &i.InvitedOn, &i.ProjectMemberInvitedID)
return i, err
}
const getProjectMembersForProjectID = `-- name: GetProjectMembersForProjectID :many
SELECT project_member_id, project_id, user_id, added_at, role_code FROM project_member WHERE project_id = $1
`

View File

@ -9,6 +9,9 @@ import (
)
type Querier interface {
CreateConfirmToken(ctx context.Context, email string) (UserAccountConfirmToken, error)
CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error)
CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error)
CreateLabelColor(ctx context.Context, arg CreateLabelColorParams) (LabelColor, error)
CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error)
CreateNotificationObject(ctx context.Context, arg CreateNotificationObjectParams) (NotificationObject, error)
@ -30,10 +33,14 @@ type Querier interface {
CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error)
CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, error)
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
DeleteConfirmTokenForEmail(ctx context.Context, email string) error
DeleteExpiredTokens(ctx context.Context) error
DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error
DeleteInvitedUserAccount(ctx context.Context, userAccountInvitedID uuid.UUID) (UserAccountInvited, error)
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) error
DeleteProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) error
DeleteProjectMember(ctx context.Context, arg DeleteProjectMemberParams) error
DeleteProjectMemberInvitedForEmail(ctx context.Context, email string) error
DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error
DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error
DeleteTaskAssignedByID(ctx context.Context, arg DeleteTaskAssignedByIDParams) (TaskAssigned, error)
@ -47,20 +54,27 @@ type Querier interface {
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberParams) error
DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) error
DeleteUserAccountInvitedForEmail(ctx context.Context, email string) error
GetAllNotificationsForUserID(ctx context.Context, notifierID uuid.UUID) ([]Notification, error)
GetAllOrganizations(ctx context.Context) ([]Organization, error)
GetAllProjects(ctx context.Context) ([]Project, error)
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)
GetAllTaskGroups(ctx context.Context) ([]TaskGroup, error)
GetAllTasks(ctx context.Context) ([]Task, error)
GetAllTeamProjects(ctx context.Context) ([]Project, error)
GetAllTeams(ctx context.Context) ([]Team, 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)
GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error)
GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error)
GetEntityForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetEntityForNotificationIDRow, error)
GetEntityIDForNotificationID(ctx context.Context, notificationID uuid.UUID) (uuid.UUID, error)
GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error)
GetInvitedUserAccounts(ctx context.Context) ([]UserAccountInvited, error)
GetInvitedUserByEmail(ctx context.Context, email string) (UserAccountInvited, error)
GetLabelColorByID(ctx context.Context, labelColorID uuid.UUID) (LabelColor, error)
GetLabelColors(ctx context.Context) ([]LabelColor, error)
GetMemberData(ctx context.Context, projectID uuid.UUID) ([]UserAccount, error)
GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
GetMemberTeamIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error)
GetNotificationForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetNotificationForNotificationIDRow, error)
@ -72,8 +86,10 @@ type Querier interface {
GetProjectIDForTaskGroup(ctx context.Context, taskGroupID uuid.UUID) (uuid.UUID, error)
GetProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) (ProjectLabel, error)
GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error)
GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, error)
GetProjectMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]ProjectMember, error)
GetProjectRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetProjectRolesForUserIDRow, error)
GetProjectsForInvitedMember(ctx context.Context, email string) ([]uuid.UUID, error)
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
@ -97,12 +113,17 @@ type Querier interface {
GetTeamRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetTeamRolesForUserIDRow, error)
GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error)
GetTeamsForUserIDWhereAdmin(ctx context.Context, userID uuid.UUID) ([]Team, error)
GetUserAccountByEmail(ctx context.Context, email string) (UserAccount, error)
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)
GetUserRolesForProject(ctx context.Context, arg GetUserRolesForProjectParams) (GetUserRolesForProjectRow, error)
HasActiveUser(ctx context.Context) (bool, error)
HasAnyUser(ctx context.Context) (bool, error)
SetFirstUserActive(ctx context.Context) (UserAccount, error)
SetTaskChecklistItemComplete(ctx context.Context, arg SetTaskChecklistItemCompleteParams) (TaskChecklistItem, error)
SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams) (Task, error)
SetTaskGroupName(ctx context.Context, arg SetTaskGroupNameParams) (TaskGroup, error)
SetUserActiveByEmail(ctx context.Context, email string) (UserAccount, error)
SetUserPassword(ctx context.Context, arg SetUserPasswordParams) (UserAccount, error)
UpdateProjectLabel(ctx context.Context, arg UpdateProjectLabelParams) (ProjectLabel, error)
UpdateProjectLabelColor(ctx context.Context, arg UpdateProjectLabelColorParams) (ProjectLabel, error)

View File

@ -43,6 +43,21 @@ SELECT project_id, role_code FROM project_member WHERE user_id = $1;
-- name: GetMemberProjectIDsForUserID :many
SELECT project_id FROM project_member WHERE user_id = $1;
-- name: GetInvitedMembersForProjectID :many
SELECT uai.user_account_invited_id, email, invited_on FROM project_member_invited AS pmi
INNER JOIN user_account_invited AS uai
ON uai.user_account_invited_id = pmi.user_account_invited_id
WHERE project_id = $1;
-- name: GetProjectMemberInvitedIDByEmail :one
SELECT email, invited_on, project_member_invited_id FROM user_account_invited AS uai
inner join project_member_invited AS pmi
ON pmi.user_account_invited_id = uai.user_account_invited_id
WHERE email = $1;
-- name: DeleteInvitedProjectMemberByID :exec
DELETE FROM project_member_invited WHERE project_member_invited_id = $1;
-- name: GetAllVisibleProjectsForUserID :many
SELECT project.* FROM project LEFT JOIN
project_member ON project_member.project_id = project.project_id WHERE project_member.user_id = $1;

View File

@ -7,14 +7,22 @@ SELECT * FROM user_account WHERE username != 'system';
-- name: GetUserAccountByUsername :one
SELECT * FROM user_account WHERE username = $1;
-- name: GetUserAccountByEmail :one
SELECT * FROM user_account WHERE email = $1;
-- name: CreateUserAccount :one
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *;
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash, role_code, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *;
-- name: UpdateUserAccountProfileAvatarURL :one
UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1
RETURNING *;
-- name: GetMemberData :many
SELECT * FROM user_account
WHERE username != 'system'
AND user_id NOT IN (SELECT user_id FROM project_member WHERE project_id = $1);
-- name: UpdateUserAccountInfo :one
UPDATE user_account SET bio = $2, full_name = $3, initials = $4, email = $5
WHERE user_id = $1 RETURNING *;
@ -32,3 +40,64 @@ UPDATE user_account SET role_code = $2 WHERE user_id = $1 RETURNING *;
-- name: SetUserPassword :one
UPDATE user_account SET password_hash = $2 WHERE user_id = $1 RETURNING *;
-- name: CreateInvitedUser :one
INSERT INTO user_account_invited (email) VALUES ($1) RETURNING *;
-- name: GetInvitedUserByEmail :one
SELECT * FROM user_account_invited WHERE email = $1;
-- name: CreateInvitedProjectMember :one
INSERT INTO project_member_invited (project_id, user_account_invited_id) VALUES ($1, $2)
RETURNING *;
-- name: GetInvitedUserAccounts :many
SELECT * FROM user_account_invited;
-- name: DeleteInvitedUserAccount :one
DELETE FROM user_account_invited WHERE user_account_invited_id = $1 RETURNING *;
-- name: HasAnyUser :one
SELECT EXISTS(SELECT 1 FROM user_account WHERE username != 'system');
-- name: HasActiveUser :one
SELECT EXISTS(SELECT 1 FROM user_account WHERE username != 'system' AND active = true);
-- name: CreateConfirmToken :one
INSERT INTO user_account_confirm_token (email) VALUES ($1) RETURNING *;
-- name: GetConfirmTokenByEmail :one
SELECT * FROM user_account_confirm_token WHERE email = $1;
-- name: GetConfirmTokenByID :one
SELECT * FROM user_account_confirm_token WHERE confirm_token_id = $1;
-- name: SetFirstUserActive :one
UPDATE user_account SET active = true WHERE user_id = (
SELECT user_id from user_account WHERE active = false LIMIT 1
) RETURNING *;
-- name: SetUserActiveByEmail :one
UPDATE user_account SET active = true WHERE email = $1 RETURNING *;
-- name: GetProjectsForInvitedMember :many
SELECT project_id FROM user_account_invited AS uai
INNER JOIN project_member_invited AS pmi
ON pmi.user_account_invited_id = uai.user_account_invited_id
WHERE uai.email = $1;
-- name: DeleteProjectMemberInvitedForEmail :exec
DELETE FROM project_member_invited WHERE project_member_invited_id IN (
SELECT pmi.project_member_invited_id FROM user_account_invited AS uai
INNER JOIN project_member_invited AS pmi
ON pmi.user_account_invited_id = uai.user_account_invited_id
WHERE uai.email = $1
);
-- name: DeleteUserAccountInvitedForEmail :exec
DELETE FROM user_account_invited WHERE email = $1;
-- name: DeleteConfirmTokenForEmail :exec
DELETE FROM user_account_confirm_token WHERE email = $1;

View File

@ -11,9 +11,53 @@ import (
"github.com/google/uuid"
)
const createConfirmToken = `-- name: CreateConfirmToken :one
INSERT INTO user_account_confirm_token (email) VALUES ($1) RETURNING confirm_token_id, email
`
func (q *Queries) CreateConfirmToken(ctx context.Context, email string) (UserAccountConfirmToken, error) {
row := q.db.QueryRowContext(ctx, createConfirmToken, email)
var i UserAccountConfirmToken
err := row.Scan(&i.ConfirmTokenID, &i.Email)
return i, err
}
const createInvitedProjectMember = `-- name: CreateInvitedProjectMember :one
INSERT INTO project_member_invited (project_id, user_account_invited_id) VALUES ($1, $2)
RETURNING project_member_invited_id, project_id, user_account_invited_id
`
type CreateInvitedProjectMemberParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
}
func (q *Queries) CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error) {
row := q.db.QueryRowContext(ctx, createInvitedProjectMember, arg.ProjectID, arg.UserAccountInvitedID)
var i ProjectMemberInvited
err := row.Scan(&i.ProjectMemberInvitedID, &i.ProjectID, &i.UserAccountInvitedID)
return i, err
}
const createInvitedUser = `-- name: CreateInvitedUser :one
INSERT INTO user_account_invited (email) VALUES ($1) RETURNING user_account_invited_id, email, invited_on, has_joined
`
func (q *Queries) CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error) {
row := q.db.QueryRowContext(ctx, createInvitedUser, email)
var i UserAccountInvited
err := row.Scan(
&i.UserAccountInvitedID,
&i.Email,
&i.InvitedOn,
&i.HasJoined,
)
return i, err
}
const createUserAccount = `-- name: CreateUserAccount :one
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash, role_code, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active
`
type CreateUserAccountParams struct {
@ -24,6 +68,7 @@ type CreateUserAccountParams struct {
CreatedAt time.Time `json:"created_at"`
PasswordHash string `json:"password_hash"`
RoleCode string `json:"role_code"`
Active bool `json:"active"`
}
func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error) {
@ -35,6 +80,7 @@ func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountPa
arg.CreatedAt,
arg.PasswordHash,
arg.RoleCode,
arg.Active,
)
var i UserAccount
err := row.Scan(
@ -49,10 +95,50 @@ func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountPa
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const deleteConfirmTokenForEmail = `-- name: DeleteConfirmTokenForEmail :exec
DELETE FROM user_account_confirm_token WHERE email = $1
`
func (q *Queries) DeleteConfirmTokenForEmail(ctx context.Context, email string) error {
_, err := q.db.ExecContext(ctx, deleteConfirmTokenForEmail, email)
return err
}
const deleteInvitedUserAccount = `-- name: DeleteInvitedUserAccount :one
DELETE FROM user_account_invited WHERE user_account_invited_id = $1 RETURNING user_account_invited_id, email, invited_on, has_joined
`
func (q *Queries) DeleteInvitedUserAccount(ctx context.Context, userAccountInvitedID uuid.UUID) (UserAccountInvited, error) {
row := q.db.QueryRowContext(ctx, deleteInvitedUserAccount, userAccountInvitedID)
var i UserAccountInvited
err := row.Scan(
&i.UserAccountInvitedID,
&i.Email,
&i.InvitedOn,
&i.HasJoined,
)
return i, err
}
const deleteProjectMemberInvitedForEmail = `-- name: DeleteProjectMemberInvitedForEmail :exec
DELETE FROM project_member_invited WHERE project_member_invited_id IN (
SELECT pmi.project_member_invited_id FROM user_account_invited AS uai
INNER JOIN project_member_invited AS pmi
ON pmi.user_account_invited_id = uai.user_account_invited_id
WHERE uai.email = $1
)
`
func (q *Queries) DeleteProjectMemberInvitedForEmail(ctx context.Context, email string) error {
_, err := q.db.ExecContext(ctx, deleteProjectMemberInvitedForEmail, email)
return err
}
const deleteUserAccountByID = `-- name: DeleteUserAccountByID :exec
DELETE FROM user_account WHERE user_id = $1
`
@ -62,8 +148,17 @@ func (q *Queries) DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) e
return err
}
const deleteUserAccountInvitedForEmail = `-- name: DeleteUserAccountInvitedForEmail :exec
DELETE FROM user_account_invited WHERE email = $1
`
func (q *Queries) DeleteUserAccountInvitedForEmail(ctx context.Context, email string) error {
_, err := q.db.ExecContext(ctx, deleteUserAccountInvitedForEmail, email)
return err
}
const getAllUserAccounts = `-- name: GetAllUserAccounts :many
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio FROM user_account WHERE username != 'system'
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM user_account WHERE username != 'system'
`
func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) {
@ -87,6 +182,7 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
); err != nil {
return nil, err
}
@ -101,6 +197,148 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
return items, nil
}
const getConfirmTokenByEmail = `-- name: GetConfirmTokenByEmail :one
SELECT confirm_token_id, email FROM user_account_confirm_token WHERE email = $1
`
func (q *Queries) GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error) {
row := q.db.QueryRowContext(ctx, getConfirmTokenByEmail, email)
var i UserAccountConfirmToken
err := row.Scan(&i.ConfirmTokenID, &i.Email)
return i, err
}
const getConfirmTokenByID = `-- name: GetConfirmTokenByID :one
SELECT confirm_token_id, email FROM user_account_confirm_token WHERE confirm_token_id = $1
`
func (q *Queries) GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error) {
row := q.db.QueryRowContext(ctx, getConfirmTokenByID, confirmTokenID)
var i UserAccountConfirmToken
err := row.Scan(&i.ConfirmTokenID, &i.Email)
return i, err
}
const getInvitedUserAccounts = `-- name: GetInvitedUserAccounts :many
SELECT user_account_invited_id, email, invited_on, has_joined FROM user_account_invited
`
func (q *Queries) GetInvitedUserAccounts(ctx context.Context) ([]UserAccountInvited, error) {
rows, err := q.db.QueryContext(ctx, getInvitedUserAccounts)
if err != nil {
return nil, err
}
defer rows.Close()
var items []UserAccountInvited
for rows.Next() {
var i UserAccountInvited
if err := rows.Scan(
&i.UserAccountInvitedID,
&i.Email,
&i.InvitedOn,
&i.HasJoined,
); 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 getInvitedUserByEmail = `-- name: GetInvitedUserByEmail :one
SELECT user_account_invited_id, email, invited_on, has_joined FROM user_account_invited WHERE email = $1
`
func (q *Queries) GetInvitedUserByEmail(ctx context.Context, email string) (UserAccountInvited, error) {
row := q.db.QueryRowContext(ctx, getInvitedUserByEmail, email)
var i UserAccountInvited
err := row.Scan(
&i.UserAccountInvitedID,
&i.Email,
&i.InvitedOn,
&i.HasJoined,
)
return i, err
}
const getMemberData = `-- name: GetMemberData :many
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM user_account
WHERE username != 'system'
AND user_id NOT IN (SELECT user_id FROM project_member WHERE project_id = $1)
`
func (q *Queries) GetMemberData(ctx context.Context, projectID uuid.UUID) ([]UserAccount, error) {
rows, err := q.db.QueryContext(ctx, getMemberData, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []UserAccount
for rows.Next() {
var i UserAccount
if err := rows.Scan(
&i.UserID,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
); 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 getProjectsForInvitedMember = `-- name: GetProjectsForInvitedMember :many
SELECT project_id FROM user_account_invited AS uai
INNER JOIN project_member_invited AS pmi
ON pmi.user_account_invited_id = uai.user_account_invited_id
WHERE uai.email = $1
`
func (q *Queries) GetProjectsForInvitedMember(ctx context.Context, email string) ([]uuid.UUID, error) {
rows, err := q.db.QueryContext(ctx, getProjectsForInvitedMember, email)
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 getRoleForUserID = `-- name: GetRoleForUserID :one
SELECT username, role.code, role.name FROM user_account
INNER JOIN role ON role.code = user_account.role_code
@ -120,8 +358,32 @@ func (q *Queries) GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRo
return i, err
}
const getUserAccountByEmail = `-- name: GetUserAccountByEmail :one
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM user_account WHERE email = $1
`
func (q *Queries) GetUserAccountByEmail(ctx context.Context, email string) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, getUserAccountByEmail, email)
var i UserAccount
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const getUserAccountByID = `-- name: GetUserAccountByID :one
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio FROM user_account WHERE user_id = $1
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM user_account WHERE user_id = $1
`
func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) {
@ -139,12 +401,13 @@ func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (Use
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const getUserAccountByUsername = `-- name: GetUserAccountByUsername :one
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio FROM user_account WHERE username = $1
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active FROM user_account WHERE username = $1
`
func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) {
@ -162,12 +425,85 @@ func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string)
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const hasActiveUser = `-- name: HasActiveUser :one
SELECT EXISTS(SELECT 1 FROM user_account WHERE username != 'system' AND active = true)
`
func (q *Queries) HasActiveUser(ctx context.Context) (bool, error) {
row := q.db.QueryRowContext(ctx, hasActiveUser)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const hasAnyUser = `-- name: HasAnyUser :one
SELECT EXISTS(SELECT 1 FROM user_account WHERE username != 'system')
`
func (q *Queries) HasAnyUser(ctx context.Context) (bool, error) {
row := q.db.QueryRowContext(ctx, hasAnyUser)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const setFirstUserActive = `-- name: SetFirstUserActive :one
UPDATE user_account SET active = true WHERE user_id = (
SELECT user_id from user_account WHERE active = false LIMIT 1
) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active
`
func (q *Queries) SetFirstUserActive(ctx context.Context) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, setFirstUserActive)
var i UserAccount
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const setUserActiveByEmail = `-- name: SetUserActiveByEmail :one
UPDATE user_account SET active = true WHERE email = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active
`
func (q *Queries) SetUserActiveByEmail(ctx context.Context, email string) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, setUserActiveByEmail, email)
var i UserAccount
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const setUserPassword = `-- name: SetUserPassword :one
UPDATE user_account SET password_hash = $2 WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
UPDATE user_account SET password_hash = $2 WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active
`
type SetUserPasswordParams struct {
@ -190,13 +526,14 @@ func (q *Queries) SetUserPassword(ctx context.Context, arg SetUserPasswordParams
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const updateUserAccountInfo = `-- name: UpdateUserAccountInfo :one
UPDATE user_account SET bio = $2, full_name = $3, initials = $4, email = $5
WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active
`
type UpdateUserAccountInfoParams struct {
@ -228,13 +565,14 @@ func (q *Queries) UpdateUserAccountInfo(ctx context.Context, arg UpdateUserAccou
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const updateUserAccountProfileAvatarURL = `-- name: UpdateUserAccountProfileAvatarURL :one
UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1
RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active
`
type UpdateUserAccountProfileAvatarURLParams struct {
@ -257,12 +595,13 @@ func (q *Queries) UpdateUserAccountProfileAvatarURL(ctx context.Context, arg Upd
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}
const updateUserRole = `-- name: UpdateUserRole :one
UPDATE user_account SET role_code = $2 WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
UPDATE user_account SET role_code = $2 WHERE user_id = $1 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio, active
`
type UpdateUserRoleParams struct {
@ -285,6 +624,7 @@ func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams)
&i.ProfileAvatarUrl,
&i.RoleCode,
&i.Bio,
&i.Active,
)
return i, err
}

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ import (
"github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger"
"github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror"
@ -63,10 +64,10 @@ func NewHandler(repo db.Repository) http.Handler {
default:
fieldName = "ProjectID"
}
log.WithFields(log.Fields{"typeArg": typeArg, "fieldName": fieldName}).Info("getting field by name")
logger.New(ctx).WithFields(log.Fields{"typeArg": typeArg, "fieldName": fieldName}).Info("getting field by name")
subjectField := val.FieldByName(fieldName)
if !subjectField.IsValid() {
log.Error("subject field name does not exist on input type")
logger.New(ctx).Error("subject field name does not exist on input type")
return nil, errors.New("subject field name does not exist on input type")
}
if fieldName == "TeamID" && subjectField.IsNil() {
@ -76,13 +77,13 @@ func NewHandler(repo db.Repository) http.Handler {
}
subjectID, ok = subjectField.Interface().(uuid.UUID)
if !ok {
log.Error("error while casting subject UUID")
logger.New(ctx).Error("error while casting subject UUID")
return nil, errors.New("error while casting subject uuid")
}
var err error
if level == ActionLevelProject {
log.WithFields(log.Fields{"subjectID": subjectID}).Info("fetching subject ID by typeArg")
logger.New(ctx).WithFields(log.Fields{"subjectID": subjectID}).Info("fetching subject ID by typeArg")
if typeArg == ObjectTypeTask {
subjectID, err = repo.GetProjectIDForTask(ctx, subjectID)
}
@ -96,7 +97,7 @@ func NewHandler(repo db.Repository) http.Handler {
subjectID, err = repo.GetProjectIDForTaskChecklistItem(ctx, subjectID)
}
if err != nil {
log.WithError(err).Error("error while getting subject ID")
logger.New(ctx).WithError(err).Error("error while getting subject ID")
return nil, err
}
projectRoles, err := GetProjectRoles(ctx, repo, subjectID)
@ -109,13 +110,13 @@ func NewHandler(repo db.Repository) http.Handler {
},
}
}
log.WithError(err).Error("error while getting project roles")
logger.New(ctx).WithError(err).Error("error while getting project roles")
return nil, err
}
for _, validRole := range roles {
log.WithFields(log.Fields{"validRole": validRole}).Info("checking role")
logger.New(ctx).WithFields(log.Fields{"validRole": validRole}).Info("checking role")
if CompareRoleLevel(projectRoles.TeamRole, validRole) || CompareRoleLevel(projectRoles.ProjectRole, validRole) {
log.WithFields(log.Fields{"teamRole": projectRoles.TeamRole, "projectRole": projectRoles.ProjectRole}).Info("is team or project role")
logger.New(ctx).WithFields(log.Fields{"teamRole": projectRoles.TeamRole, "projectRole": projectRoles.ProjectRole}).Info("is team or project role")
return next(ctx)
}
}
@ -132,7 +133,7 @@ func NewHandler(repo db.Repository) http.Handler {
}
role, err := repo.GetTeamRoleForUserID(ctx, db.GetTeamRoleForUserIDParams{UserID: userID, TeamID: subjectID})
if err != nil {
log.WithError(err).Error("error while getting team roles for user ID")
logger.New(ctx).WithError(err).Error("error while getting team roles for user ID")
return nil, err
}
for _, validRole := range roles {

View File

@ -27,16 +27,6 @@ type ChecklistBadge struct {
Total int `json:"total"`
}
type CreateProjectMember struct {
ProjectID uuid.UUID `json:"projectID"`
UserID uuid.UUID `json:"userID"`
}
type CreateProjectMemberPayload struct {
Ok bool `json:"ok"`
Member *Member `json:"member"`
}
type CreateTaskChecklist struct {
TaskID uuid.UUID `json:"taskID"`
Name string `json:"name"`
@ -59,6 +49,23 @@ type CreateTeamMemberPayload struct {
TeamMember *Member `json:"teamMember"`
}
type DeleteInvitedProjectMember struct {
ProjectID uuid.UUID `json:"projectID"`
Email string `json:"email"`
}
type DeleteInvitedProjectMemberPayload struct {
InvitedMember *InvitedMember `json:"invitedMember"`
}
type DeleteInvitedUserAccount struct {
InvitedUserID uuid.UUID `json:"invitedUserID"`
}
type DeleteInvitedUserAccountPayload struct {
InvitedUser *InvitedUserAccount `json:"invitedUser"`
}
type DeleteProject struct {
ProjectID uuid.UUID `json:"projectID"`
}
@ -187,6 +194,30 @@ type FindUser struct {
UserID uuid.UUID `json:"userID"`
}
type InviteProjectMembers struct {
ProjectID uuid.UUID `json:"projectID"`
Members []MemberInvite `json:"members"`
}
type InviteProjectMembersPayload struct {
Ok bool `json:"ok"`
ProjectID uuid.UUID `json:"projectID"`
Members []Member `json:"members"`
InvitedMembers []InvitedMember `json:"invitedMembers"`
}
type InvitedMember struct {
Email string `json:"email"`
InvitedOn time.Time `json:"invitedOn"`
}
type InvitedUserAccount struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
InvitedOn time.Time `json:"invitedOn"`
Member *MemberList `json:"member"`
}
type LogoutUser struct {
UserID uuid.UUID `json:"userID"`
}
@ -207,11 +238,28 @@ type Member struct {
Member *MemberList `json:"member"`
}
type MemberInvite struct {
UserID *uuid.UUID `json:"userID"`
Email *string `json:"email"`
}
type MemberList struct {
Teams []db.Team `json:"teams"`
Projects []db.Project `json:"projects"`
}
type MemberSearchFilter struct {
SearchFilter string `json:"searchFilter"`
ProjectID *uuid.UUID `json:"projectID"`
}
type MemberSearchResult struct {
Similarity int `json:"similarity"`
ID string `json:"id"`
User *db.UserAccount `json:"user"`
Status ShareStatus `json:"status"`
}
type NewProject struct {
TeamID *uuid.UUID `json:"teamID"`
Name string `json:"name"`
@ -781,3 +829,44 @@ func (e *RoleLevel) UnmarshalGQL(v interface{}) error {
func (e RoleLevel) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type ShareStatus string
const (
ShareStatusInvited ShareStatus = "INVITED"
ShareStatusJoined ShareStatus = "JOINED"
)
var AllShareStatus = []ShareStatus{
ShareStatusInvited,
ShareStatusJoined,
}
func (e ShareStatus) IsValid() bool {
switch e {
case ShareStatusInvited, ShareStatusJoined:
return true
}
return false
}
func (e ShareStatus) String() string {
return string(e)
}
func (e *ShareStatus) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ShareStatus(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ShareStatus", str)
}
return nil
}
func (e ShareStatus) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}

View File

@ -86,6 +86,13 @@ type UserAccount {
member: MemberList!
}
type InvitedUserAccount {
id: ID!
email: String!
invitedOn: Time!
member: MemberList!
}
type Team {
id: ID!
createdAt: Time!
@ -93,6 +100,12 @@ type Team {
members: [Member!]!
}
type InvitedMember {
email: String!
invitedOn: Time!
}
type Project {
id: ID!
createdAt: Time!
@ -100,6 +113,7 @@ type Project {
team: Team
taskGroups: [TaskGroup!]!
members: [Member!]!
invitedMembers: [InvitedMember!]!
labels: [ProjectLabel!]!
}
@ -158,6 +172,11 @@ type TaskChecklist {
items: [TaskChecklistItem!]!
}
enum ShareStatus {
INVITED
JOINED
}
enum RoleLevel {
ADMIN
MEMBER
@ -184,6 +203,7 @@ directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!)
type Query {
organizations: [Organization!]!
users: [UserAccount!]!
invitedUsers: [InvitedUserAccount!]!
findUser(input: FindUser!): UserAccount!
findProject(input: FindProject!):
Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)
@ -338,22 +358,41 @@ input UpdateProjectLabelColor {
}
extend type Mutation {
createProjectMember(input: CreateProjectMember!):
CreateProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
inviteProjectMembers(input: InviteProjectMembers!):
InviteProjectMembersPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteProjectMember(input: DeleteProjectMember!):
DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectMemberRole(input: UpdateProjectMemberRole!):
UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteInvitedProjectMember(input: DeleteInvitedProjectMember!):
DeleteInvitedProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
}
input CreateProjectMember {
input DeleteInvitedProjectMember {
projectID: UUID!
userID: UUID!
email: String!
}
type CreateProjectMemberPayload {
type DeleteInvitedProjectMemberPayload {
invitedMember: InvitedMember!
}
input MemberInvite {
userID: UUID
email: String
}
input InviteProjectMembers {
projectID: UUID!
members: [MemberInvite!]!
}
type InviteProjectMembersPayload {
ok: Boolean!
member: Member!
projectID: UUID!
members: [Member!]!
invitedMembers: [InvitedMember!]!
}
input DeleteProjectMember {
@ -723,6 +762,8 @@ extend type Mutation {
UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteUserAccount(input: DeleteUserAccount!):
DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteInvitedUserAccount(input: DeleteInvitedUserAccount!):
DeleteInvitedUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
logoutUser(input: LogoutUser!): Boolean!
clearProfileAvatar: UserAccount!
@ -734,6 +775,31 @@ extend type Mutation {
UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
}
extend type Query {
searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]!
}
input DeleteInvitedUserAccount {
invitedUserID: UUID!
}
type DeleteInvitedUserAccountPayload {
invitedUser: InvitedUserAccount!
}
input MemberSearchFilter {
searchFilter: String!
projectID: UUID
}
type MemberSearchResult {
similarity: Int!
id: String!
user: UserAccount
status: ShareStatus!
}
type UpdateUserInfoPayload {
user: UserAccount!
}
@ -790,3 +856,4 @@ type DeleteUserAccountPayload {
ok: Boolean!
userAccount: UserAccount!
}

View File

@ -5,7 +5,9 @@ package graph
import (
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"time"
@ -13,6 +15,11 @@ import (
"github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger"
"github.com/lithammer/fuzzysearch/fuzzy"
gomail "gopkg.in/mail.v2"
hermes "github.com/matcornic/hermes/v2"
log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror"
"golang.org/x/crypto/bcrypt"
@ -28,7 +35,7 @@ func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject)
return &db.Project{}, errors.New("user id is missing")
}
createdAt := time.Now().UTC()
log.WithFields(log.Fields{"name": input.Name, "teamID": input.TeamID}).Info("creating new project")
logger.New(ctx).WithFields(log.Fields{"name": input.Name, "teamID": input.TeamID}).Info("creating new project")
var project db.Project
var err error
if input.TeamID == nil {
@ -37,10 +44,10 @@ func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject)
Name: input.Name,
})
if err != nil {
log.WithError(err).Error("error while creating project")
logger.New(ctx).WithError(err).Error("error while creating project")
return &db.Project{}, err
}
log.WithFields(log.Fields{"userID": userID, "projectID": project.ProjectID}).Info("creating personal project link")
logger.New(ctx).WithField("projectID", project.ProjectID).Info("creating personal project link")
} else {
project, err = r.Repository.CreateTeamProject(ctx, db.CreateTeamProjectParams{
CreatedAt: createdAt,
@ -48,13 +55,13 @@ func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject)
TeamID: *input.TeamID,
})
if err != nil {
log.WithError(err).Error("error while creating project")
logger.New(ctx).WithError(err).Error("error while creating project")
return &db.Project{}, err
}
}
_, err = r.Repository.CreateProjectMember(ctx, db.CreateProjectMemberParams{ProjectID: project.ProjectID, UserID: userID, AddedAt: createdAt, RoleCode: "admin"})
if err != nil {
log.WithError(err).Error("error while creating initial project member")
logger.New(ctx).WithError(err).Error("error while creating initial project member")
return &db.Project{}, err
}
return &project, nil
@ -123,33 +130,162 @@ func (r *mutationResolver) UpdateProjectLabelColor(ctx context.Context, input Up
return &label, err
}
func (r *mutationResolver) CreateProjectMember(ctx context.Context, input CreateProjectMember) (*CreateProjectMemberPayload, error) {
addedAt := time.Now().UTC()
_, err := r.Repository.CreateProjectMember(ctx, db.CreateProjectMemberParams{ProjectID: input.ProjectID, UserID: input.UserID, AddedAt: addedAt, RoleCode: "member"})
if err != nil {
return &CreateProjectMemberPayload{Ok: false}, err
}
user, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err != nil {
return &CreateProjectMemberPayload{Ok: false}, err
}
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input InviteProjectMembers) (*InviteProjectMembersPayload, error) {
members := []Member{}
invitedMembers := []InvitedMember{}
for _, invitedMember := range input.Members {
if invitedMember.Email != nil && invitedMember.UserID != nil {
return &InviteProjectMembersPayload{Ok: false}, &gqlerror.Error{
Message: "Both email and userID can not be used to invite a project member",
Extensions: map[string]interface{}{
"code": "403",
},
}
} else if invitedMember.Email == nil && invitedMember.UserID == nil {
return &InviteProjectMembersPayload{Ok: false}, &gqlerror.Error{
Message: "Either email or userID must be set to invite a project member",
Extensions: map[string]interface{}{
"code": "403",
},
}
}
if invitedMember.UserID != nil {
// Invite by user ID
addedAt := time.Now().UTC()
_, err := r.Repository.CreateProjectMember(ctx, db.CreateProjectMemberParams{ProjectID: input.ProjectID, UserID: *invitedMember.UserID, AddedAt: addedAt, RoleCode: "member"})
if err != nil {
return &InviteProjectMembersPayload{Ok: false}, err
}
user, err := r.Repository.GetUserAccountByID(ctx, *invitedMember.UserID)
if err != nil && err != sql.ErrNoRows {
return &InviteProjectMembersPayload{Ok: false}, err
role, err := r.Repository.GetRoleForProjectMemberByUserID(ctx, db.GetRoleForProjectMemberByUserIDParams{UserID: input.UserID, ProjectID: input.ProjectID})
if err != nil {
return &CreateProjectMemberPayload{Ok: false}, 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: *invitedMember.UserID, ProjectID: input.ProjectID})
if err != nil {
return &InviteProjectMembersPayload{Ok: false}, err
}
members = append(members, Member{
ID: *invitedMember.UserID,
FullName: user.FullName,
Username: user.Username,
ProfileIcon: profileIcon,
Role: &db.Role{Code: role.Code, Name: role.Name},
})
} else {
// Invite by email
// if invited user does not exist, create entry
invitedUser, err := r.Repository.GetInvitedUserByEmail(ctx, *invitedMember.Email)
now := time.Now().UTC()
if err != nil {
if err == sql.ErrNoRows {
invitedUser, err = r.Repository.CreateInvitedUser(ctx, *invitedMember.Email)
if err != nil {
return &InviteProjectMembersPayload{Ok: false}, err
}
confirmToken, err := r.Repository.CreateConfirmToken(ctx, *invitedMember.Email)
if err != nil {
return &InviteProjectMembersPayload{Ok: false}, err
}
// send out invitation
// add project invite entry
// send out notification?
h := hermes.Hermes{
// Optional Theme
Product: hermes.Product{
// Appears in header & footer of e-mails
Name: "Taskscafe",
Link: "http://localhost:3333/",
// Optional product logo
Logo: "https://github.com/JordanKnott/taskcafe/raw/master/.github/taskcafe-full.png",
},
}
email := hermes.Email{
Body: hermes.Body{
Name: "Jordan Knott",
Intros: []string{
"You have been invited to join Taskcafe",
},
Actions: []hermes.Action{
{
Instructions: "To get started with Taskcafe, please click here:",
Button: hermes.Button{
Color: "#7367F0", // Optional action button color
TextColor: "#FFFFFF",
Text: "Register your account",
Link: "http://localhost:3000/register?confirmToken=" + confirmToken.ConfirmTokenID.String(),
},
},
},
Outros: []string{
"Need help, or have questions? Just reply to this email, we'd love to help.",
},
},
}
// Generate an HTML email with the provided contents (for modern clients)
emailBody, err := h.GenerateHTML(email)
if err != nil {
panic(err) // Tip: Handle error with something else than a panic ;)
}
emailBodyPlain, err := h.GeneratePlainText(email)
if err != nil {
panic(err) // Tip: Handle error with something else than a panic ;)
}
m := gomail.NewMessage()
// Set E-Mail sender
m.SetHeader("From", "no-reply@taskcafe.com")
// Set E-Mail receivers
m.SetHeader("To", invitedUser.Email)
// Set E-Mail subject
m.SetHeader("Subject", "You have been invited to Taskcafe")
// Set E-Mail body. You can set plain text or html with text/html
m.SetBody("text/html", emailBody)
m.AddAlternative("text/plain", emailBodyPlain)
// Settings for SMTP server
d := gomail.NewDialer("127.0.0.1", 11500, "no-reply@taskcafe.com", "")
// This is only needed when SSL/TLS certificate is not valid on server.
// In production this should be set to false.
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
// Now send E-Mail
if err := d.DialAndSend(m); err != nil {
fmt.Println(err)
panic(err)
}
} else {
return &InviteProjectMembersPayload{Ok: false}, err
}
}
_, err = r.Repository.CreateInvitedProjectMember(ctx, db.CreateInvitedProjectMemberParams{
ProjectID: input.ProjectID,
UserAccountInvitedID: invitedUser.UserAccountInvitedID,
})
if err != nil {
return &InviteProjectMembersPayload{Ok: false}, err
}
logger.New(ctx).Info("adding invited member")
invitedMembers = append(invitedMembers, InvitedMember{Email: *invitedMember.Email, InvitedOn: now})
}
}
return &CreateProjectMemberPayload{Ok: true, Member: &Member{
ID: input.UserID,
FullName: user.FullName,
Username: user.Username,
ProfileIcon: profileIcon,
Role: &db.Role{Code: role.Code, Name: role.Name},
}}, nil
return &InviteProjectMembersPayload{Ok: false, ProjectID: input.ProjectID, Members: members, InvitedMembers: invitedMembers}, nil
}
func (r *mutationResolver) DeleteProjectMember(ctx context.Context, input DeleteProjectMember) (*DeleteProjectMemberPayload, error) {
@ -181,18 +317,18 @@ func (r *mutationResolver) DeleteProjectMember(ctx context.Context, input Delete
func (r *mutationResolver) UpdateProjectMemberRole(ctx context.Context, input UpdateProjectMemberRole) (*UpdateProjectMemberRolePayload, error) {
user, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err != nil {
log.WithError(err).Error("get user account")
logger.New(ctx).WithError(err).Error("get user account")
return &UpdateProjectMemberRolePayload{Ok: false}, err
}
_, err = r.Repository.UpdateProjectMemberRole(ctx, db.UpdateProjectMemberRoleParams{ProjectID: input.ProjectID,
UserID: input.UserID, RoleCode: input.RoleCode.String()})
if err != nil {
log.WithError(err).Error("update project member role")
logger.New(ctx).WithError(err).Error("update project member role")
return &UpdateProjectMemberRolePayload{Ok: false}, err
}
role, err := r.Repository.GetRoleForProjectMemberByUserID(ctx, db.GetRoleForProjectMemberByUserIDParams{UserID: user.UserID, ProjectID: input.ProjectID})
if err != nil {
log.WithError(err).Error("get role for project member")
logger.New(ctx).WithError(err).Error("get role for project member")
return &UpdateProjectMemberRolePayload{Ok: false}, err
}
var url *string
@ -209,19 +345,33 @@ func (r *mutationResolver) UpdateProjectMemberRole(ctx context.Context, input Up
return &UpdateProjectMemberRolePayload{Ok: true, Member: &member}, err
}
func (r *mutationResolver) DeleteInvitedProjectMember(ctx context.Context, input DeleteInvitedProjectMember) (*DeleteInvitedProjectMemberPayload, error) {
member, err := r.Repository.GetProjectMemberInvitedIDByEmail(ctx, input.Email)
if err != nil {
return &DeleteInvitedProjectMemberPayload{}, err
}
err = r.Repository.DeleteInvitedProjectMemberByID(ctx, member.ProjectMemberInvitedID)
if err != nil {
return &DeleteInvitedProjectMemberPayload{}, err
}
return &DeleteInvitedProjectMemberPayload{
InvitedMember: &InvitedMember{Email: member.Email, InvitedOn: member.InvitedOn},
}, nil
}
func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.Task, error) {
createdAt := time.Now().UTC()
log.WithFields(log.Fields{"positon": input.Position, "taskGroupID": input.TaskGroupID}).Info("creating task")
logger.New(ctx).WithFields(log.Fields{"positon": input.Position, "taskGroupID": input.TaskGroupID}).Info("creating task")
task, err := r.Repository.CreateTask(ctx, db.CreateTaskParams{input.TaskGroupID, createdAt, input.Name, input.Position})
if err != nil {
log.WithError(err).Error("issue while creating task")
logger.New(ctx).WithError(err).Error("issue while creating task")
return &db.Task{}, err
}
return &task, nil
}
func (r *mutationResolver) DeleteTask(ctx context.Context, input DeleteTaskInput) (*DeleteTaskPayload, error) {
log.WithFields(log.Fields{
logger.New(ctx).WithFields(log.Fields{
"taskID": input.TaskID,
}).Info("deleting task")
err := r.Repository.DeleteTaskByID(ctx, input.TaskID)
@ -278,8 +428,8 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
func (r *mutationResolver) AssignTask(ctx context.Context, input *AssignTaskInput) (*db.Task, error) {
assignedDate := time.Now().UTC()
assignedTask, err := r.Repository.CreateTaskAssigned(ctx, db.CreateTaskAssignedParams{input.TaskID, input.UserID, assignedDate})
log.WithFields(log.Fields{
"userID": assignedTask.UserID,
logger.New(ctx).WithFields(log.Fields{
"assignedUserID": assignedTask.UserID,
"taskID": assignedTask.TaskID,
"assignedTaskID": assignedTask.TaskAssignedID,
}).Info("assigned task")
@ -589,7 +739,7 @@ func (r *mutationResolver) ToggleTaskLabel(ctx context.Context, input ToggleTask
createdAt := time.Now().UTC()
if err == sql.ErrNoRows {
log.WithFields(log.Fields{"err": err}).Warning("no rows")
logger.New(ctx).WithFields(log.Fields{"err": err}).Warning("no rows")
_, err := r.Repository.CreateTaskLabelForTask(ctx, db.CreateTaskLabelForTaskParams{
TaskID: input.TaskID,
ProjectLabelID: input.ProjectLabelID,
@ -622,17 +772,17 @@ func (r *mutationResolver) ToggleTaskLabel(ctx context.Context, input ToggleTask
func (r *mutationResolver) DeleteTeam(ctx context.Context, input DeleteTeam) (*DeleteTeamPayload, error) {
team, err := r.Repository.GetTeamByID(ctx, input.TeamID)
if err != nil {
log.Error(err)
logger.New(ctx).Error(err)
return &DeleteTeamPayload{Ok: false}, err
}
projects, err := r.Repository.GetAllProjectsForTeam(ctx, input.TeamID)
if err != nil {
log.Error(err)
logger.New(ctx).Error(err)
return &DeleteTeamPayload{Ok: false}, err
}
err = r.Repository.DeleteTeamByID(ctx, input.TeamID)
if err != nil {
log.Error(err)
logger.New(ctx).Error(err)
return &DeleteTeamPayload{Ok: false}, err
}
@ -687,18 +837,18 @@ func (r *mutationResolver) CreateTeamMember(ctx context.Context, input CreateTea
func (r *mutationResolver) UpdateTeamMemberRole(ctx context.Context, input UpdateTeamMemberRole) (*UpdateTeamMemberRolePayload, error) {
user, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err != nil {
log.WithError(err).Error("get user account")
logger.New(ctx).WithError(err).Error("get user account")
return &UpdateTeamMemberRolePayload{Ok: false}, err
}
_, err = r.Repository.UpdateTeamMemberRole(ctx, db.UpdateTeamMemberRoleParams{TeamID: input.TeamID,
UserID: input.UserID, RoleCode: input.RoleCode.String()})
if err != nil {
log.WithError(err).Error("update project member role")
logger.New(ctx).WithError(err).Error("update project member role")
return &UpdateTeamMemberRolePayload{Ok: false}, err
}
role, err := r.Repository.GetRoleForTeamMember(ctx, db.GetRoleForTeamMemberParams{UserID: user.UserID, TeamID: input.TeamID})
if err != nil {
log.WithError(err).Error("get role for project member")
logger.New(ctx).WithError(err).Error("get role for project member")
return &UpdateTeamMemberRolePayload{Ok: false}, err
}
var url *string
@ -785,6 +935,25 @@ func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUs
return &DeleteUserAccountPayload{UserAccount: &user, Ok: true}, nil
}
func (r *mutationResolver) DeleteInvitedUserAccount(ctx context.Context, input DeleteInvitedUserAccount) (*DeleteInvitedUserAccountPayload, error) {
user, err := r.Repository.DeleteInvitedUserAccount(ctx, input.InvitedUserID)
if err != nil {
return &DeleteInvitedUserAccountPayload{}, err
}
err = r.Repository.DeleteConfirmTokenForEmail(ctx, user.Email)
if err != nil {
logger.New(ctx).WithError(err).Error("issue deleting confirm token")
return &DeleteInvitedUserAccountPayload{}, err
}
return &DeleteInvitedUserAccountPayload{
InvitedUser: &InvitedUserAccount{
Email: user.Email,
ID: user.UserAccountInvitedID,
InvitedOn: user.InvitedOn,
},
}, err
}
func (r *mutationResolver) LogoutUser(ctx context.Context, input LogoutUser) (bool, error) {
err := r.Repository.DeleteRefreshTokenByUserID(ctx, input.UserID)
return true, err
@ -850,9 +1019,9 @@ func (r *notificationResolver) ID(ctx context.Context, obj *db.Notification) (uu
}
func (r *notificationResolver) Entity(ctx context.Context, obj *db.Notification) (*NotificationEntity, error) {
log.WithFields(log.Fields{"notificationID": obj.NotificationID}).Info("fetching entity for notification")
logger.New(ctx).WithFields(log.Fields{"notificationID": obj.NotificationID}).Info("fetching entity for notification")
entity, err := r.Repository.GetEntityForNotificationID(ctx, obj.NotificationID)
log.WithFields(log.Fields{"entityID": entity.EntityID}).Info("fetched entity")
logger.New(ctx).WithFields(log.Fields{"entityID": entity.EntityID}).Info("fetched entity")
if err != nil {
return &NotificationEntity{}, err
}
@ -884,7 +1053,7 @@ func (r *notificationResolver) Actor(ctx context.Context, obj *db.Notification)
if err != nil {
return &NotificationActor{}, err
}
log.WithFields(log.Fields{"entityID": entity.ActorID}).Info("fetching actor")
logger.New(ctx).WithFields(log.Fields{"entityID": entity.ActorID}).Info("fetching actor")
user, err := r.Repository.GetUserAccountByID(ctx, entity.ActorID)
if err != nil {
return &NotificationActor{}, err
@ -914,7 +1083,7 @@ func (r *projectResolver) Team(ctx context.Context, obj *db.Project) (*db.Team,
if err == sql.ErrNoRows {
return nil, nil
}
log.WithFields(log.Fields{"teamID": obj.TeamID, "projectID": obj.ProjectID}).WithError(err).Error("issue while getting team for project")
logger.New(ctx).WithFields(log.Fields{"teamID": obj.TeamID, "projectID": obj.ProjectID}).WithError(err).Error("issue while getting team for project")
return &team, err
}
return &team, nil
@ -928,14 +1097,14 @@ func (r *projectResolver) Members(ctx context.Context, obj *db.Project) ([]Membe
members := []Member{}
projectMembers, err := r.Repository.GetProjectMembersForProjectID(ctx, obj.ProjectID)
if err != nil {
log.WithError(err).Error("get project members for project id")
logger.New(ctx).WithError(err).Error("get project members for project id")
return members, err
}
for _, projectMember := range projectMembers {
user, err := r.Repository.GetUserAccountByID(ctx, projectMember.UserID)
if err != nil {
log.WithError(err).Error("get user account by ID")
logger.New(ctx).WithError(err).Error("get user account by ID")
return members, err
}
var url *string
@ -944,7 +1113,7 @@ func (r *projectResolver) Members(ctx context.Context, obj *db.Project) ([]Membe
}
role, err := r.Repository.GetRoleForProjectMemberByUserID(ctx, db.GetRoleForProjectMemberByUserIDParams{UserID: user.UserID, ProjectID: obj.ProjectID})
if err != nil {
log.WithError(err).Error("get role for projet member by user ID")
logger.New(ctx).WithError(err).Error("get role for projet member by user ID")
return members, err
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
@ -955,6 +1124,18 @@ func (r *projectResolver) Members(ctx context.Context, obj *db.Project) ([]Membe
return members, nil
}
func (r *projectResolver) InvitedMembers(ctx context.Context, obj *db.Project) ([]InvitedMember, error) {
members, err := r.Repository.GetInvitedMembersForProjectID(ctx, obj.ProjectID)
if err != nil && err == sql.ErrNoRows {
return []InvitedMember{}, nil
}
invited := []InvitedMember{}
for _, member := range members {
invited = append(invited, InvitedMember{Email: member.Email, InvitedOn: member.InvitedOn})
}
return invited, err
}
func (r *projectResolver) Labels(ctx context.Context, obj *db.Project) ([]db.ProjectLabel, error) {
labels, err := r.Repository.GetProjectLabelsForProject(ctx, obj.ProjectID)
return labels, err
@ -988,6 +1169,25 @@ func (r *queryResolver) Users(ctx context.Context) ([]db.UserAccount, error) {
return r.Repository.GetAllUserAccounts(ctx)
}
func (r *queryResolver) InvitedUsers(ctx context.Context) ([]InvitedUserAccount, error) {
invitedMembers, err := r.Repository.GetInvitedUserAccounts(ctx)
if err != nil {
if err == sql.ErrNoRows {
return []InvitedUserAccount{}, nil
}
return []InvitedUserAccount{}, err
}
members := []InvitedUserAccount{}
for _, invitedMember := range invitedMembers {
members = append(members, InvitedUserAccount{
ID: invitedMember.UserAccountInvitedID,
Email: invitedMember.Email,
InvitedOn: invitedMember.InvitedOn,
})
}
return members, nil
}
func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*db.UserAccount, error) {
account, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err == sql.ErrNoRows {
@ -1002,11 +1202,7 @@ func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*db.UserA
}
func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*db.Project, error) {
userID, role, ok := GetUser(ctx)
log.WithFields(log.Fields{"userID": userID, "role": role}).Info("find project user")
if !ok {
return &db.Project{}, nil
}
logger.New(ctx).Info("finding project user")
project, err := r.Repository.GetProjectByID(ctx, input.ProjectID)
if err == sql.ErrNoRows {
return &db.Project{}, &gqlerror.Error{
@ -1027,10 +1223,10 @@ func (r *queryResolver) FindTask(ctx context.Context, input FindTask) (*db.Task,
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")
logger.New(ctx).Info("user id was not found from middleware")
return []db.Project{}, nil
}
log.WithFields(log.Fields{"userID": userID}).Info("fetching projects")
logger.New(ctx).Info("fetching projects")
if input != nil {
return r.Repository.GetAllProjectsForTeam(ctx, *input.TeamID)
@ -1046,37 +1242,36 @@ func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]
projects := make(map[string]db.Project)
for _, team := range teams {
log.WithFields(log.Fields{"teamID": team.TeamID}).Info("found team")
logger.New(ctx).WithField("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")
logger.New(ctx).WithField("projectID", project.ProjectID).Info("adding team project")
projects[project.ProjectID.String()] = project
}
}
visibleProjects, err := r.Repository.GetAllVisibleProjectsForUserID(ctx, userID)
if err != nil {
log.WithField("userID", userID).Info("error getting visible projects for user")
logger.New(ctx).Info("error getting visible projects for user")
return []db.Project{}, nil
}
for _, project := range visibleProjects {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("found visible project")
logger.New(ctx).WithField("projectID", project.ProjectID).Info("found visible project")
if _, ok := projects[project.ProjectID.String()]; !ok {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("adding visible project")
logger.New(ctx).WithField("projectID", project.ProjectID).Info("adding visible project")
projects[project.ProjectID.String()] = project
}
}
log.WithFields(log.Fields{"projectLength": len(projects)}).Info("making projects")
logger.New(ctx).WithField("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")
logger.New(ctx).WithField("projectID", project.ProjectID).Info("adding project to final list")
allProjects = append(allProjects, project)
}
log.Info(allProjects)
return allProjects, nil
}
@ -1091,7 +1286,7 @@ func (r *queryResolver) FindTeam(ctx context.Context, input FindTeam) (*db.Team,
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!")
logger.New(ctx).Error("userID or org role does not exist")
return []db.Team{}, errors.New("internal error")
}
if orgRole == "admin" {
@ -1102,7 +1297,7 @@ func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
teams := make(map[string]db.Team)
adminTeams, err := r.Repository.GetTeamsForUserIDWhereAdmin(ctx, userID)
if err != nil {
log.WithError(err).Error("error while getting teams for user ID")
logger.New(ctx).WithError(err).Error("error while getting teams for user ID")
return []db.Team{}, err
}
@ -1112,19 +1307,19 @@ func (r *queryResolver) Teams(ctx context.Context) ([]db.Team, error) {
visibleProjects, err := r.Repository.GetAllVisibleProjectsForUserID(ctx, userID)
if err != nil {
log.WithField("userID", userID).WithError(err).Error("error while getting visible projects for user ID")
logger.New(ctx).WithError(err).Error("error while getting visible projects for user ID")
return []db.Team{}, err
}
for _, project := range visibleProjects {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("found visible project")
logger.New(ctx).WithField("projectID", project.ProjectID).Info("found visible project")
if _, ok := teams[project.ProjectID.String()]; !ok {
log.WithFields(log.Fields{"projectID": project.ProjectID.String()}).Info("adding visible project")
logger.New(ctx).WithField("projectID", project.ProjectID).Info("adding visible project")
team, err := r.Repository.GetTeamByID(ctx, project.TeamID)
if err != nil {
if err == sql.ErrNoRows {
continue
}
log.WithField("teamID", project.TeamID).WithError(err).Error("error getting team by id")
logger.New(ctx).WithField("teamID", project.TeamID).WithError(err).Error("error getting team by id")
return []db.Team{}, err
}
teams[project.TeamID.String()] = team
@ -1152,7 +1347,7 @@ func (r *queryResolver) Me(ctx context.Context) (*MePayload, error) {
}
user, err := r.Repository.GetUserAccountByID(ctx, userID)
if err == sql.ErrNoRows {
log.WithFields(log.Fields{"userID": userID}).Warning("can not find user for me query")
logger.New(ctx).Warning("can not find user for me query")
return &MePayload{}, nil
} else if err != nil {
return &MePayload{}, err
@ -1180,7 +1375,7 @@ func (r *queryResolver) Me(ctx context.Context) (*MePayload, error) {
func (r *queryResolver) Notifications(ctx context.Context) ([]db.Notification, error) {
userID, ok := GetUserID(ctx)
log.WithFields(log.Fields{"userID": userID}).Info("fetching notifications")
logger.New(ctx).Info("fetching notifications")
if !ok {
return []db.Notification{}, errors.New("user id is missing")
}
@ -1193,6 +1388,65 @@ func (r *queryResolver) Notifications(ctx context.Context) ([]db.Notification, e
return notifications, nil
}
func (r *queryResolver) SearchMembers(ctx context.Context, input MemberSearchFilter) ([]MemberSearchResult, error) {
availableMembers, err := r.Repository.GetMemberData(ctx, *input.ProjectID)
if err != nil {
logger.New(ctx).WithField("projectID", input.ProjectID).WithError(err).Error("error while getting member data")
return []MemberSearchResult{}, err
}
invitedMembers, err := r.Repository.GetInvitedMembersForProjectID(ctx, *input.ProjectID)
if err != nil {
logger.New(ctx).WithField("projectID", input.ProjectID).WithError(err).Error("error while getting member data")
return []MemberSearchResult{}, err
}
sortList := []string{}
masterList := map[string]MasterEntry{}
for _, member := range availableMembers {
sortList = append(sortList, member.Username)
sortList = append(sortList, member.Email)
masterList[member.Username] = MasterEntry{ID: member.UserID, MemberType: MemberTypeJoined}
masterList[member.Email] = MasterEntry{ID: member.UserID, MemberType: MemberTypeJoined}
}
for _, member := range invitedMembers {
sortList = append(sortList, member.Email)
logger.New(ctx).WithField("Email", member.Email).Info("adding member")
masterList[member.Email] = MasterEntry{ID: member.UserAccountInvitedID, MemberType: MemberTypeInvited}
}
logger.New(ctx).WithField("searchFilter", input.SearchFilter).Info(sortList)
rankedList := fuzzy.RankFind(input.SearchFilter, sortList)
logger.New(ctx).Info(rankedList)
results := []MemberSearchResult{}
memberList := map[uuid.UUID]bool{}
for _, rank := range rankedList {
entry, _ := masterList[rank.Target]
_, ok := memberList[entry.ID]
logger.New(ctx).WithField("ok", ok).WithField("target", rank.Target).Info("checking rank")
if !ok {
if entry.MemberType == MemberTypeJoined {
logger.New(ctx).WithFields(log.Fields{"source": rank.Source, "target": rank.Target}).Info("searching")
entry := masterList[rank.Target]
user, err := r.Repository.GetUserAccountByID(ctx, entry.ID)
if err != nil {
if err == sql.ErrNoRows {
continue
}
return []MemberSearchResult{}, err
}
results = append(results, MemberSearchResult{ID: user.UserID.String(), User: &user, Status: ShareStatusJoined, Similarity: rank.Distance})
} else {
logger.New(ctx).WithField("id", rank.Target).Info("adding target")
results = append(results, MemberSearchResult{ID: rank.Target, Status: ShareStatusInvited, Similarity: rank.Distance})
}
memberList[entry.ID] = true
}
}
return results, nil
}
func (r *refreshTokenResolver) ID(ctx context.Context, obj *db.RefreshToken) (uuid.UUID, error) {
return obj.TokenID, nil
}
@ -1257,7 +1511,7 @@ func (r *taskResolver) Assigned(ctx context.Context, obj *db.Task) ([]Member, er
if err == sql.ErrNoRows {
role = db.Role{Code: "owner", Name: "Owner"}
} else {
log.WithFields(log.Fields{"userID": user.UserID}).WithError(err).Error("get role for project member")
logger.New(ctx).WithError(err).Error("get role for project member")
return taskMembers, err
}
}
@ -1351,14 +1605,14 @@ func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, err
teamMembers, err := r.Repository.GetTeamMembersForTeamID(ctx, obj.TeamID)
if err != nil {
log.WithError(err).Error("get project members for project id")
logger.New(ctx).Error("get project members for project id")
return members, err
}
for _, teamMember := range teamMembers {
user, err := r.Repository.GetUserAccountByID(ctx, teamMember.UserID)
if err != nil {
log.WithError(err).Error("get user account by ID")
logger.New(ctx).WithError(err).Error("get user account by ID")
return members, err
}
var url *string
@ -1367,7 +1621,7 @@ func (r *teamResolver) Members(ctx context.Context, obj *db.Team) ([]Member, err
}
role, err := r.Repository.GetRoleForTeamMember(ctx, db.GetRoleForTeamMemberParams{UserID: user.UserID, TeamID: obj.TeamID})
if err != nil {
log.WithError(err).Error("get role for projet member by user ID")
logger.New(ctx).WithError(err).Error("get role for projet member by user ID")
return members, err
}
@ -1395,8 +1649,7 @@ func (r *userAccountResolver) ID(ctx context.Context, obj *db.UserAccount) (uuid
func (r *userAccountResolver) Role(ctx context.Context, obj *db.UserAccount) (*db.Role, error) {
role, err := r.Repository.GetRoleForUserID(ctx, obj.UserID)
if err != nil {
log.Info("beep!")
log.WithError(err).Error("get role for user id")
logger.New(ctx).WithError(err).Error("get role for user id")
return &db.Role{}, err
}
return &db.Role{Code: role.Code, Name: role.Name}, nil
@ -1506,3 +1759,21 @@ type taskGroupResolver struct{ *Resolver }
type taskLabelResolver struct{ *Resolver }
type teamResolver struct{ *Resolver }
type userAccountResolver struct{ *Resolver }
// !!! WARNING !!!
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
// one last chance to move it out of harms way if you want. There are two reasons this happens:
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
// it when you're done.
// - You have helper methods in this file. Move them out to keep these resolver files clean.
type MemberType string
const (
MemberTypeInvited MemberType = "INVITED"
MemberTypeJoined MemberType = "JOINED"
)
type MasterEntry struct {
MemberType MemberType
ID uuid.UUID
}

View File

@ -86,6 +86,13 @@ type UserAccount {
member: MemberList!
}
type InvitedUserAccount {
id: ID!
email: String!
invitedOn: Time!
member: MemberList!
}
type Team {
id: ID!
createdAt: Time!
@ -93,6 +100,12 @@ type Team {
members: [Member!]!
}
type InvitedMember {
email: String!
invitedOn: Time!
}
type Project {
id: ID!
createdAt: Time!
@ -100,6 +113,7 @@ type Project {
team: Team
taskGroups: [TaskGroup!]!
members: [Member!]!
invitedMembers: [InvitedMember!]!
labels: [ProjectLabel!]!
}

View File

@ -1,3 +1,8 @@
enum ShareStatus {
INVITED
JOINED
}
enum RoleLevel {
ADMIN
MEMBER
@ -24,6 +29,7 @@ directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!)
type Query {
organizations: [Organization!]!
users: [UserAccount!]!
invitedUsers: [InvitedUserAccount!]!
findUser(input: FindUser!): UserAccount!
findProject(input: FindProject!):
Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)

View File

@ -1,20 +1,39 @@
extend type Mutation {
createProjectMember(input: CreateProjectMember!):
CreateProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
inviteProjectMembers(input: InviteProjectMembers!):
InviteProjectMembersPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteProjectMember(input: DeleteProjectMember!):
DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectMemberRole(input: UpdateProjectMemberRole!):
UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteInvitedProjectMember(input: DeleteInvitedProjectMember!):
DeleteInvitedProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
}
input CreateProjectMember {
input DeleteInvitedProjectMember {
projectID: UUID!
userID: UUID!
email: String!
}
type CreateProjectMemberPayload {
type DeleteInvitedProjectMemberPayload {
invitedMember: InvitedMember!
}
input MemberInvite {
userID: UUID
email: String
}
input InviteProjectMembers {
projectID: UUID!
members: [MemberInvite!]!
}
type InviteProjectMembersPayload {
ok: Boolean!
member: Member!
projectID: UUID!
members: [Member!]!
invitedMembers: [InvitedMember!]!
}
input DeleteProjectMember {

View File

@ -4,6 +4,8 @@ extend type Mutation {
UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteUserAccount(input: DeleteUserAccount!):
DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteInvitedUserAccount(input: DeleteInvitedUserAccount!):
DeleteInvitedUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
logoutUser(input: LogoutUser!): Boolean!
clearProfileAvatar: UserAccount!
@ -15,6 +17,31 @@ extend type Mutation {
UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
}
extend type Query {
searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]!
}
input DeleteInvitedUserAccount {
invitedUserID: UUID!
}
type DeleteInvitedUserAccountPayload {
invitedUser: InvitedUserAccount!
}
input MemberSearchFilter {
searchFilter: String!
projectID: UUID
}
type MemberSearchResult {
similarity: Int!
id: String!
user: UserAccount
status: ShareStatus!
}
type UpdateUserInfoPayload {
user: UserAccount!
}

View File

@ -1,89 +1,21 @@
package logger
import (
"fmt"
"net/http"
"time"
"context"
"github.com/go-chi/chi/middleware"
"github.com/sirupsen/logrus"
"github.com/google/uuid"
"github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus"
)
// NewStructuredLogger creates a new logger for chi router
func NewStructuredLogger(logger *logrus.Logger) func(next http.Handler) http.Handler {
return middleware.RequestLogger(&StructuredLogger{logger})
}
// StructuredLogger is a logger for chi router
type StructuredLogger struct {
Logger *logrus.Logger
}
// NewLogEntry creates a new log entry for the given HTTP request
func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry {
entry := &StructuredLoggerEntry{Logger: logrus.NewEntry(l.Logger)}
logFields := logrus.Fields{}
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
logFields["req_id"] = reqID
// New returns a log entry with the reqID and userID fields populated if they exist
func New(ctx context.Context) *log.Entry {
entry := log.NewEntry(log.StandardLogger())
if reqID, ok := ctx.Value(utils.ReqIDKey).(uuid.UUID); ok {
entry = entry.WithField("reqID", reqID)
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
if userID, ok := ctx.Value(utils.UserIDKey).(uuid.UUID); ok {
entry = entry.WithField("userID", userID)
}
logFields["http_scheme"] = scheme
logFields["http_proto"] = r.Proto
logFields["http_method"] = r.Method
logFields["remote_addr"] = r.RemoteAddr
logFields["user_agent"] = r.UserAgent()
logFields["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
entry.Logger = entry.Logger.WithFields(logFields)
return entry
}
// StructuredLoggerEntry is a log entry will all relevant information about a specific http request
type StructuredLoggerEntry struct {
Logger logrus.FieldLogger
}
// Write logs information about http request response body
func (l *StructuredLoggerEntry) Write(status, bytes int, elapsed time.Duration) {
l.Logger = l.Logger.WithFields(logrus.Fields{
"resp_status": status, "resp_bytes_length": bytes,
"resp_elapsed_ms": float64(elapsed.Nanoseconds()) / 1000000.0,
})
l.Logger.Debugln("request complete")
}
// Panic logs if the request panics
func (l *StructuredLoggerEntry) Panic(v interface{}, stack []byte) {
l.Logger = l.Logger.WithFields(logrus.Fields{
"stack": string(stack),
"panic": fmt.Sprintf("%+v", v),
})
}
// GetLogEntry helper function for getting log entry for request
func GetLogEntry(r *http.Request) logrus.FieldLogger {
entry := middleware.GetLogEntry(r).(*StructuredLoggerEntry)
return entry.Logger
}
// LogEntrySetField sets a key's value
func LogEntrySetField(r *http.Request, key string, value interface{}) {
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
entry.Logger = entry.Logger.WithField(key, value)
}
}
// LogEntrySetFields sets the log entry's fields
func LogEntrySetFields(r *http.Request, fields map[string]interface{}) {
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
entry.Logger = entry.Logger.WithFields(fields)
}
}

View File

@ -0,0 +1,89 @@
package logger
import (
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/middleware"
"github.com/sirupsen/logrus"
)
// NewStructuredLogger creates a new logger for chi router
func NewStructuredLogger(logger *logrus.Logger) func(next http.Handler) http.Handler {
return middleware.RequestLogger(&StructuredLogger{logger})
}
// StructuredLogger is a logger for chi router
type StructuredLogger struct {
Logger *logrus.Logger
}
// NewLogEntry creates a new log entry for the given HTTP request
func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry {
entry := &StructuredLoggerEntry{Logger: logrus.NewEntry(l.Logger)}
logFields := logrus.Fields{}
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
logFields["req_id"] = reqID
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
logFields["http_scheme"] = scheme
logFields["http_proto"] = r.Proto
logFields["http_method"] = r.Method
logFields["remote_addr"] = r.RemoteAddr
logFields["user_agent"] = r.UserAgent()
logFields["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
entry.Logger = entry.Logger.WithFields(logFields)
return entry
}
// StructuredLoggerEntry is a log entry will all relevant information about a specific http request
type StructuredLoggerEntry struct {
Logger logrus.FieldLogger
}
// Write logs information about http request response body
func (l *StructuredLoggerEntry) Write(status, bytes int, elapsed time.Duration) {
l.Logger = l.Logger.WithFields(logrus.Fields{
"resp_status": status, "resp_bytes_length": bytes,
"resp_elapsed_ms": float64(elapsed.Nanoseconds()) / 1000000.0,
})
l.Logger.Debugln("request complete")
}
// Panic logs if the request panics
func (l *StructuredLoggerEntry) Panic(v interface{}, stack []byte) {
l.Logger = l.Logger.WithFields(logrus.Fields{
"stack": string(stack),
"panic": fmt.Sprintf("%+v", v),
})
}
// GetLogEntry helper function for getting log entry for request
func GetLogEntry(r *http.Request) logrus.FieldLogger {
entry := middleware.GetLogEntry(r).(*StructuredLoggerEntry)
return entry.Logger
}
// LogEntrySetField sets a key's value
func LogEntrySetField(r *http.Request, key string, value interface{}) {
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
entry.Logger = entry.Logger.WithField(key, value)
}
}
// LogEntrySetFields sets the log entry's fields
func LogEntrySetFields(r *http.Request, fields map[string]interface{}) {
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
entry.Logger = entry.Logger.WithFields(fields)
}
}

View File

@ -31,15 +31,33 @@ type NewUserAccount struct {
Email string
}
// RegisterUserRequestData is the request data for registering a new user (duh)
type RegisterUserRequestData struct {
User NewUserAccount
}
type RegisteredUserResponseData struct {
Setup bool `json:"setup"`
}
// ConfirmUserRequestData is the request data for upgrading an invited user to a normal user
type ConfirmUserRequestData struct {
ConfirmToken string
}
// InstallRequestData is the request data for installing new Taskcafe app
type InstallRequestData struct {
User NewUserAccount
}
type Setup struct {
ConfirmToken string `json:"confirmToken"`
}
// LoginResponseData is the response data for when a user logs in
type LoginResponseData struct {
AccessToken string `json:"accessToken"`
IsInstalled bool `json:"isInstalled"`
Setup bool `json:"setup"`
}
// LogoutResponseData is the response data for when a user logs out
@ -60,30 +78,24 @@ type AvatarUploadResponseData struct {
// RefreshTokenHandler handles when a user attempts to refresh token
func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
if err == sql.ErrNoRows {
user, err := h.repo.GetUserAccountByUsername(r.Context(), "system")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.Header().Set("Content-type", "application/json")
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: false})
userExists, err := h.repo.HasAnyUser(r.Context())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.WithError(err).Error("issue while fetching if user accounts exist")
return
} else if err != nil {
log.WithError(err).Error("get system option")
w.WriteHeader(http.StatusBadRequest)
}
log.WithField("userExists", userExists).Info("checking if setup")
if !userExists {
w.Header().Set("Content-type", "application/json")
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: "", Setup: true})
return
}
c, err := r.Cookie("refreshToken")
if err != nil {
if err == http.ErrNoCookie {
log.Warn("no cookie")
w.WriteHeader(http.StatusBadRequest)
return
}
@ -112,6 +124,14 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
return
}
if !user.Active {
log.WithFields(log.Fields{
"username": user.Username,
}).Warn("attempt to refresh token with inactive user")
w.WriteHeader(http.StatusUnauthorized)
return
}
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{token.UserID, refreshCreatedAt, refreshExpiresAt})
@ -119,13 +139,17 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
err = h.repo.DeleteRefreshTokenByID(r.Context(), token.TokenID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
log.Info("here 1")
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
log.Info("here 2")
w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{
Name: "refreshToken",
@ -133,7 +157,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: true})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, Setup: false})
}
// LogoutHandler removes all refresh tokens to log out user
@ -175,6 +199,14 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
if !user.Active {
log.WithFields(log.Fields{
"username": requestData.Username,
}).Warn("attempt to login with inactive user")
w.WriteHeader(http.StatusUnauthorized)
return
}
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(requestData.Password))
if err != nil {
log.WithFields(log.Fields{
@ -203,6 +235,7 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
// TODO: remove
// InstallHandler creates first user on fresh install
func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request) {
if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok {
@ -266,6 +299,172 @@ func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) {
usersExist, err := h.repo.HasActiveUser(r.Context())
if err != nil {
log.WithError(err).Error("issue checking if user accounts exist")
w.WriteHeader(http.StatusInternalServerError)
return
}
var user db.UserAccount
if !usersExist {
log.Info("setting first inactive user to active")
user, err = h.repo.SetFirstUserActive(r.Context())
if err != nil {
log.WithError(err).Error("issue checking if user accounts exist")
w.WriteHeader(http.StatusInternalServerError)
return
}
} else {
var requestData ConfirmUserRequestData
err = json.NewDecoder(r.Body).Decode(&requestData)
if err != nil {
log.WithError(err).Error("issue decoding request data")
w.WriteHeader(http.StatusBadRequest)
return
}
confirmTokenID, err := uuid.Parse(requestData.ConfirmToken)
if err != nil {
log.WithError(err).Error("issue parsing confirm token")
w.WriteHeader(http.StatusBadRequest)
return
}
confirmToken, err := h.repo.GetConfirmTokenByID(r.Context(), confirmTokenID)
if err != nil {
log.WithError(err).Error("issue getting token by id")
w.WriteHeader(http.StatusBadRequest)
return
}
user, err = h.repo.SetUserActiveByEmail(r.Context(), confirmToken.Email)
if err != nil {
log.WithError(err).Error("issue getting account by email")
w.WriteHeader(http.StatusBadRequest)
return
}
}
now := time.Now().UTC()
projects, err := h.repo.GetProjectsForInvitedMember(r.Context(), user.Email)
for _, project := range projects {
member, err := h.repo.CreateProjectMember(r.Context(),
db.CreateProjectMemberParams{
ProjectID: project,
UserID: user.UserID,
AddedAt: now,
RoleCode: "member",
},
)
if err != nil {
log.WithError(err).Error("issue creating project member")
w.WriteHeader(http.StatusInternalServerError)
return
}
log.WithField("memberID", member.ProjectMemberID).Info("creating project member")
err = h.repo.DeleteProjectMemberInvitedForEmail(r.Context(), user.Email)
if err != nil {
log.WithError(err).Error("issue deleting project member invited")
w.WriteHeader(http.StatusInternalServerError)
return
}
err = h.repo.DeleteUserAccountInvitedForEmail(r.Context(), user.Email)
if err != nil {
log.WithError(err).Error("issue deleting user account invited")
w.WriteHeader(http.StatusInternalServerError)
return
}
err = h.repo.DeleteConfirmTokenForEmail(r.Context(), user.Email)
if err != nil {
log.WithError(err).Error("issue deleting confirm token")
w.WriteHeader(http.StatusInternalServerError)
return
}
}
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{
Name: "refreshToken",
Value: refreshTokenString.TokenID.String(),
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
userExists, err := h.repo.HasAnyUser(r.Context())
if err != nil {
log.WithError(err).Error("issue checking if user accounts exist")
w.WriteHeader(http.StatusInternalServerError)
return
}
var requestData RegisterUserRequestData
err = json.NewDecoder(r.Body).Decode(&requestData)
if err != nil {
log.WithError(err).Error("issue decoding register user request data")
w.WriteHeader(http.StatusBadRequest)
return
}
if userExists {
_, err := h.repo.GetInvitedUserByEmail(r.Context(), requestData.User.Email)
if err != nil {
if err == sql.ErrNoRows {
hasActiveUser, err := h.repo.HasActiveUser(r.Context())
if err != nil {
log.WithError(err).Error("error checking for active user")
w.WriteHeader(http.StatusInternalServerError)
}
if !hasActiveUser {
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: true})
return
}
} else {
log.WithError(err).Error("error while retrieving invited user by email")
w.WriteHeader(http.StatusForbidden)
return
}
}
}
// TODO: accept user if public registration is enabled
createdAt := time.Now().UTC()
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
if err != nil {
log.Error("issue generating passoed")
w.WriteHeader(http.StatusInternalServerError)
return
}
user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{
FullName: requestData.User.FullName,
Username: requestData.User.Username,
Initials: requestData.User.Initials,
Email: requestData.User.Email,
PasswordHash: string(hashedPwd),
CreatedAt: createdAt,
RoleCode: "admin",
Active: false,
})
if err != nil {
log.Error("issue registering user account")
w.WriteHeader(http.StatusInternalServerError)
return
}
log.WithField("username", user.UserID).Info("registered new user account")
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: !userExists})
}
// Routes registers all authentication routes
func (rs authResource) Routes(taskcafeHandler TaskcafeHandler) chi.Router {
r := chi.NewRouter()

View File

@ -19,6 +19,7 @@ type AuthenticationMiddleware struct {
// Middleware returns the middleware handler
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New()
bearerTokenRaw := r.Header.Get("Authorization")
splitToken := strings.Split(bearerTokenRaw, "Bearer")
if len(splitToken) != 2 {
@ -61,6 +62,7 @@ func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
ctx := context.WithValue(r.Context(), utils.UserIDKey, userID)
ctx = context.WithValue(ctx, utils.RestrictedModeKey, accessClaims.Restricted)
ctx = context.WithValue(ctx, utils.OrgRoleKey, accessClaims.OrgRole)
ctx = context.WithValue(ctx, utils.ReqIDKey, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@ -87,13 +87,13 @@ func NewRouter(dbConnection *sqlx.DB, jwtKey []byte) (chi.Router, error) {
mux.Mount("/auth", authResource{}.Routes(taskcafeHandler))
mux.Handle("/__graphql", graph.NewPlaygroundHandler("/graphql"))
mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer))
mux.Post("/auth/confirm", taskcafeHandler.ConfirmUser)
mux.Post("/auth/register", taskcafeHandler.RegisterUser)
})
auth := AuthenticationMiddleware{jwtKey}
r.Group(func(mux chi.Router) {
mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
mux.Post("/auth/install", taskcafeHandler.InstallHandler)
mux.Handle("/graphql", graph.NewHandler(*repository))
})

View File

@ -6,6 +6,8 @@ type ContextKey string
const (
// UserIDKey is the key for the user id of the authenticated user
UserIDKey ContextKey = "userID"
// ReqIDKey is the unique ID key for current request
ReqIDKey ContextKey = "reqID"
//RestrictedModeKey is the key for whether the authenticated user only has access to install route
RestrictedModeKey ContextKey = "restricted_mode"
// OrgRoleKey is the key for the organization role code of the authenticated user

View File

@ -0,0 +1,6 @@
CREATE TABLE user_account_invited (
user_account_invited_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
email text NOT NULL UNIQUE,
invited_on timestamptz NOT NULL DEFAULT NOW(),
has_joined boolean NOT NULL DEFAULT false
);

View File

@ -0,0 +1,7 @@
CREATE TABLE project_member_invited (
project_member_invited_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id uuid NOT NULL
REFERENCES project(project_id) ON DELETE CASCADE,
user_account_invited_id uuid NOT NULL
REFERENCES user_account_invited(user_account_invited_id) ON DELETE CASCADE
);

View File

@ -0,0 +1,2 @@
ALTER TABLE user_account ADD COLUMN active boolean NOT NULL DEFAULT false;
UPDATE user_account SET active = true;

View File

@ -0,0 +1,4 @@
CREATE TABLE user_account_confirm_token (
confirm_token_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
email text NOT NULL UNIQUE
);