20 Commits

Author SHA1 Message Date
c7538a98e5 fix: segfault on database connection failure 2020-09-12 18:23:23 -05:00
fe84f97f18 fix: url encode avatar filename when showing path
fixes #61
2020-09-12 18:12:12 -05:00
52c60abcd7 fix: secret key is no longer hard coded
the secret key for signing JWT tokens is now read from server.secret.

if that does not exist, then a random UUID v4 is generated and used
instead. a log warning is also shown.
2020-09-12 18:03:17 -05:00
9fdb3008db docs(bug_report): add note about server logs 2020-09-12 03:33:24 -05:00
e2ef8a1a19 fix: initial access token after install is now set correctly 2020-09-12 03:24:09 -05:00
61cd376bfd fix: rename host to hostname in example config
fixes #59
2020-09-12 01:32:01 -05:00
ba9fc64fd9 fix: do not add localhost:3333 url to avatar urls
fixes #58
2020-09-12 01:23:48 -05:00
03dafe9b7b fix: remove font awesome library 2020-09-11 19:58:42 -05:00
12a767947a fix: duplicate schema migration 2020-09-11 19:29:41 -05:00
40557ba79f feat: add view raw markdown button to task details 2020-09-11 16:21:46 -05:00
e4d1e21304 docs(README): re-add screenshot 2020-09-11 15:11:56 -05:00
f7c6ee470e fix: task label margin issue with task title 2020-09-11 14:54:22 -05:00
227ce5966d fix: top navbar logo was not always centered 2020-09-11 14:43:46 -05:00
aa5e1c0661 fix: flickering when transitioning to some pages 2020-09-11 14:41:21 -05:00
b603081691 fix: task labels wrapper extending farther than it should 2020-09-11 14:36:41 -05:00
e76ea9da63 fix: show correct task group in task details 2020-09-11 14:34:57 -05:00
923d7f7372 feat: add user profile settings tab 2020-09-11 14:26:02 -05:00
009d717d80 fix: uploading avatar image failing due to invalid UUID key
fixes #55
2020-09-11 13:57:02 -05:00
4272fefa28 feat: implement task group actions
- allow sorting specifc task groups
- duplicate task group
- delete all tasks in task group
2020-09-10 23:58:10 -05:00
25f5cad557 chore: switch eslint to lint changed files intead of whole project 2020-09-10 22:35:16 -05:00
65 changed files with 2605 additions and 347 deletions

View File

@ -18,6 +18,8 @@ If applicable, add screenshots to help explain your problem.
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.
Please send the Taskcafe web service logs if applicable.
<!-- <!--
Please read the contributing guide before working on any new pull requests! Please read the contributing guide before working on any new pull requests!

View File

@ -3,11 +3,10 @@ repos:
hooks: hooks:
- id: eslint - id: eslint
name: eslint name: eslint
entry: go run cmd/mage/main.go frontend:lint entry: scripts/lint.sh
language: system language: system
files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx files: \.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx
types: [file] types: [file]
pass_filenames: false
- hooks: - hooks:
- id: check-yaml - id: check-yaml
- id: end-of-file-fixer - id: end-of-file-fixer

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Task sorting & filtering - Task sorting & filtering
- Redesigned the Task Details UI - Redesigned the Task Details UI
- Implement task group actions (duplicate/delete all tasks/sort)
### Fixed ### Fixed
- removed CORS middleware to fix security issue - removed CORS middleware to fix security issue

View File

@ -21,6 +21,8 @@ Was this project useful? Please consider <a href="https://www.buymeacoffee.com/j
**Please note that this project is still in active development. Some options may not work yet! For updates on development, join the Discord server** **Please note that this project is still in active development. Some options may not work yet! For updates on development, join the Discord server**
![Taskcafe](./.github/taskcafe_preview.png)
## Features ## Features
Currently Taskcafe only offers basic task tracking through a Kanban board. Currently Taskcafe only offers basic task tracking through a Kanban board.

View File

@ -1,5 +1,5 @@
[general] [server]
host = '0.0.0.0:3333' hostname = '0.0.0.0:3333'
[email_notifications] [email_notifications]
enabled = true enabled = true

View File

@ -6,14 +6,6 @@
"@apollo/client": "^3.0.0-rc.8", "@apollo/client": "^3.0.0-rc.8",
"@apollo/react-common": "^3.1.4", "@apollo/react-common": "^3.1.4",
"@apollo/react-hooks": "^3.1.3", "@apollo/react-hooks": "^3.1.3",
"@fortawesome/fontawesome-svg-core": "^1.2.27",
"@fortawesome/free-brands-svg-icons": "^5.12.1",
"@fortawesome/free-regular-svg-icons": "^5.12.1",
"@fortawesome/free-solid-svg-icons": "^5.12.1",
"@fortawesome/react-fontawesome": "^0.1.8",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",
"@types/date-fns": "^2.6.0", "@types/date-fns": "^2.6.0",

View File

@ -171,7 +171,7 @@ const AddUserPopup: React.FC<AddUserPopupProps> = ({ onAddUser }) => {
const AdminRoute = () => { const AdminRoute = () => {
useEffect(() => { useEffect(() => {
document.title = 'Taskcafé | Admin'; document.title = 'Admin | Taskcafé';
}, []); }, []);
const { loading, data } = useUsersQuery(); const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();

View File

@ -59,7 +59,7 @@ const Install = () => {
} else { } else {
const response: RefreshTokenResponse = await x.data; const response: RefreshTokenResponse = await x.data;
const { accessToken: newToken, isInstalled } = response; const { accessToken: newToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken); const claims: JWTToken = jwtDecode(newToken);
const currentUser = { const currentUser = {
id: claims.userId, id: claims.userId,
roles: { roles: {
@ -69,7 +69,7 @@ const Install = () => {
}, },
}; };
setUser(currentUser); setUser(currentUser);
setAccessToken(accessToken); setAccessToken(newToken);
if (!isInstalled) { if (!isInstalled) {
history.replace('/install'); history.replace('/install');
} }

View File

@ -3,11 +3,20 @@ import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar'; import GlobalTopNavbar from 'App/TopNavbar';
import { getAccessToken } from 'shared/utils/accessToken'; import { getAccessToken } from 'shared/utils/accessToken';
import Settings from 'shared/components/Settings'; import Settings from 'shared/components/Settings';
import { useMeQuery, useClearProfileAvatarMutation, useUpdateUserPasswordMutation } from 'shared/generated/graphql'; import {
useMeQuery,
useClearProfileAvatarMutation,
useUpdateUserPasswordMutation,
useUpdateUserInfoMutation,
MeQuery,
MeDocument,
} from 'shared/generated/graphql';
import axios from 'axios'; import axios from 'axios';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
const MainContent = styled.div` const MainContent = styled.div`
padding: 0 0 50px 80px; padding: 0 0 50px 80px;
@ -19,6 +28,7 @@ const Projects = () => {
const $fileUpload = useRef<HTMLInputElement>(null); const $fileUpload = useRef<HTMLInputElement>(null);
const [clearProfileAvatar] = useClearProfileAvatarMutation(); const [clearProfileAvatar] = useClearProfileAvatarMutation();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [updateUserInfo] = useUpdateUserInfoMutation();
const [updateUserPassword] = useUpdateUserPasswordMutation(); const [updateUserPassword] = useUpdateUserPasswordMutation();
const { loading, data, refetch } = useMeQuery(); const { loading, data, refetch } = useMeQuery();
useEffect(() => { useEffect(() => {
@ -69,6 +79,13 @@ const Projects = () => {
toast('Password was changed!'); toast('Password was changed!');
done(); done();
}} }}
onChangeUserInfo={(d, done) => {
updateUserInfo({
variables: { name: d.full_name, bio: d.bio, email: d.email, initials: d.initials },
});
toast('User info was saved!');
done();
}}
onProfileAvatarRemove={() => { onProfileAvatarRemove={() => {
clearProfileAvatar(); clearProfileAvatar();
}} }}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/components/Lists'; import { TaskSorting, TaskSortingType, TaskSortingDirection } from 'shared/utils/sorting';
export const ActionsList = styled.ul` export const ActionsList = styled.ul`
margin: 0; margin: 0;

View File

@ -8,6 +8,7 @@ import {
useSetTaskCompleteMutation, useSetTaskCompleteMutation,
useToggleTaskLabelMutation, useToggleTaskLabelMutation,
useFindProjectQuery, useFindProjectQuery,
useSortTaskGroupMutation,
useUpdateTaskGroupNameMutation, useUpdateTaskGroupNameMutation,
useUpdateTaskNameMutation, useUpdateTaskNameMutation,
useCreateTaskMutation, useCreateTaskMutation,
@ -21,6 +22,10 @@ import {
useUnassignTaskMutation, useUnassignTaskMutation,
useUpdateTaskDueDateMutation, useUpdateTaskDueDateMutation,
FindProjectQuery, FindProjectQuery,
useDuplicateTaskGroupMutation,
DuplicateTaskGroupMutation,
DuplicateTaskGroupDocument,
useDeleteTaskGroupTasksMutation,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import QuickCardEditor from 'shared/components/QuickCardEditor'; import QuickCardEditor from 'shared/components/QuickCardEditor';
@ -33,10 +38,8 @@ import SimpleLists, {
TaskMeta, TaskMeta,
TaskMetaMatch, TaskMetaMatch,
TaskMetaFilters, TaskMetaFilters,
TaskSorting,
TaskSortingType,
TaskSortingDirection,
} from 'shared/components/Lists'; } from 'shared/components/Lists';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
import produce from 'immer'; import produce from 'immer';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
import DueDateManager from 'shared/components/DueDateManager'; import DueDateManager from 'shared/components/DueDateManager';
@ -44,6 +47,7 @@ import EmptyBoard from 'shared/components/EmptyBoard';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
import LabelManagerEditor from 'Projects/Project/LabelManagerEditor'; import LabelManagerEditor from 'Projects/Project/LabelManagerEditor';
import Chip from 'shared/components/Chip'; import Chip from 'shared/components/Chip';
import { toast } from 'react-toastify';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import FilterStatus from './FilterStatus'; import FilterStatus from './FilterStatus';
import FilterMeta from './FilterMeta'; import FilterMeta from './FilterMeta';
@ -263,6 +267,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const [taskMetaFilters, setTaskMetaFilters] = useState(initTaskMetaFilters); const [taskMetaFilters, setTaskMetaFilters] = useState(initTaskMetaFilters);
const [taskSorting, setTaskSorting] = useState(initTaskSorting); const [taskSorting, setTaskSorting] = useState(initTaskSorting);
const history = useHistory(); const history = useHistory();
const [sortTaskGroup] = useSortTaskGroupMutation({
onCompleted: () => {
toast('List was sorted');
},
});
const [deleteTaskGroup] = useDeleteTaskGroupMutation({ const [deleteTaskGroup] = useDeleteTaskGroupMutation({
update: (client, deletedTaskGroupData) => { update: (client, deletedTaskGroupData) => {
updateApolloCache<FindProjectQuery>( updateApolloCache<FindProjectQuery>(
@ -315,6 +324,36 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
const { loading, data } = useFindProjectQuery({ const { loading, data } = useFindProjectQuery({
variables: { projectID }, variables: { projectID },
}); });
const [deleteTaskGroupTasks] = useDeleteTaskGroupTasksMutation({
update: (client, resp) =>
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const idx = cache.findProject.taskGroups.findIndex(
t => t.id === resp.data.deleteTaskGroupTasks.taskGroupID,
);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks = [];
}
}),
{ projectID },
),
});
const [duplicateTaskGroup] = useDuplicateTaskGroupMutation({
update: (client, resp) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups.push(resp.data.duplicateTaskGroup.taskGroup);
}),
{ projectID },
);
},
});
const [updateTaskDueDate] = useUpdateTaskDueDateMutation(); const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
const [setTaskComplete] = useSetTaskCompleteMutation(); const [setTaskComplete] = useSetTaskCompleteMutation();
@ -624,15 +663,44 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => { onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
showPopup( showPopup(
$targetRef, $targetRef,
<Popup title="List actions" tab={0} onClose={() => hidePopup()}> <ListActions
<ListActions taskGroupID={taskGroupID}
taskGroupID={taskGroupID} onDeleteTaskGroupTasks={() => {
onArchiveTaskGroup={tgID => { deleteTaskGroupTasks({ variables: { taskGroupID } });
deleteTaskGroup({ variables: { taskGroupID: tgID } }); hidePopup();
}}
onSortTaskGroup={taskSort => {
const taskGroup = data.findProject.taskGroups.find(t => t.id === taskGroupID);
if (taskGroup) {
const tasks: Array<{ taskID: string; position: number }> = taskGroup.tasks
.sort((a, b) => sortTasks(a, b, taskSort))
.reduce((prevTasks: Array<{ taskID: string; position: number }>, t, idx) => {
prevTasks.push({ taskID: t.id, position: (idx + 1) * 2048 });
return tasks;
}, []);
sortTaskGroup({ variables: { taskGroupID, tasks } });
hidePopup(); hidePopup();
}} }
/> }}
</Popup>, onDuplicateTaskGroup={newName => {
const idx = data.findProject.taskGroups.findIndex(t => t.id === taskGroupID);
if (idx !== -1) {
const taskGroups = data.findProject.taskGroups.sort((a, b) => a.position - b.position);
const prevPos = taskGroups[idx].position;
const next = taskGroups[idx + 1];
let newPos = prevPos * 2;
if (next) {
newPos = (prevPos + next.position) / 2.0;
}
duplicateTaskGroup({ variables: { projectID, taskGroupID, name: newName, position: newPos } });
hidePopup();
}
}}
onArchiveTaskGroup={tgID => {
deleteTaskGroup({ variables: { taskGroupID: tgID } });
hidePopup();
}}
/>,
); );
}} }}
/> />

View File

@ -8,6 +8,8 @@ import {
useCreateProjectMutation, useCreateProjectMutation,
GetProjectsDocument, GetProjectsDocument,
GetProjectsQuery, GetProjectsQuery,
MeQuery,
MeDocument,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -260,11 +262,7 @@ const Projects = () => {
}, },
}); });
if (loading) { if (loading) {
return ( return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
<>
<span>loading</span>
</>
);
} }
const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f']; const colors = ['#e362e3', '#7a6ff0', '#37c5ab', '#aa62e3', '#e8384f'];

View File

@ -85,18 +85,30 @@ type TeamsRouteProps = {
const Teams = () => { const Teams = () => {
const { teamID } = useParams<TeamsRouteProps>(); const { teamID } = useParams<TeamsRouteProps>();
const history = useHistory(); const history = useHistory();
const { loading, data } = useGetTeamQuery({ variables: { teamID } }); const { loading, data } = useGetTeamQuery({
variables: { teamID },
onCompleted: resp => {
document.title = `${resp.findTeam.name} | Taskcafé`;
},
});
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch(); const match = useRouteMatch();
useEffect(() => {
document.title = 'Teams | Taskcafé';
}, []);
if (loading) { if (loading) {
return ( return (
<> <GlobalTopNavbar
<span>loading</span> menuType={[
</> { name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
); );
} }
if (data && user) { if (data && user) {

View File

@ -16,11 +16,12 @@ import {
} from './Styles'; } from './Styles';
type NameEditorProps = { type NameEditorProps = {
buttonLabel?: string;
onSave: (listName: string) => void; onSave: (listName: string) => void;
onCancel: () => void; onCancel: () => void;
}; };
const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => { export const NameEditor: React.FC<NameEditorProps> = ({ onSave: handleSave, onCancel, buttonLabel = 'Save' }) => {
const $editorRef = useRef<HTMLTextAreaElement>(null); const $editorRef = useRef<HTMLTextAreaElement>(null);
const [listName, setListName] = useState(''); const [listName, setListName] = useState('');
useEffect(() => { useEffect(() => {
@ -28,6 +29,11 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
$editorRef.current.focus(); $editorRef.current.focus();
} }
}); });
const onSave = (newName: string) => {
if (newName.replace(/\s+/g, '') !== '') {
handleSave(newName);
}
};
const onKeyDown = (e: React.KeyboardEvent) => { const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@ -60,7 +66,7 @@ const NameEditor: React.FC<NameEditorProps> = ({ onSave, onCancel }) => {
} }
}} }}
> >
Save {buttonLabel}
</AddListButton> </AddListButton>
<CancelAdd onClick={() => onCancel()}> <CancelAdd onClick={() => onCancel()}>
<Cross width={16} height={16} /> <Cross width={16} height={16} />

View File

@ -557,6 +557,7 @@ const Admin: React.FC<AdminProps> = ({
<TabNavContent> <TabNavContent>
{items.map((item, idx) => ( {items.map((item, idx) => (
<NavItem <NavItem
key={item.name}
onClick={(tab, top) => { onClick={(tab, top) => {
if ($tabNav && $tabNav.current) { if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect(); const pos = $tabNav.current.getBoundingClientRect();

View File

@ -1,9 +1,7 @@
import styled, { css, keyframes } from 'styled-components'; import styled, { css, keyframes } from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
import TextareaAutosize from 'react-autosize-textarea'; import TextareaAutosize from 'react-autosize-textarea';
import { CheckCircle, CheckSquareOutline } from 'shared/icons'; import { CheckCircle, CheckSquareOutline, Clock } from 'shared/icons';
import { RefObject } from 'react';
import TaskAssignee from 'shared/components/TaskAssignee'; import TaskAssignee from 'shared/components/TaskAssignee';
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>` export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
@ -20,7 +18,9 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
stroke: rgba(${props.theme.colors.success}); stroke: rgba(${props.theme.colors.success});
`} `}
`; `;
export const ClockIcon = styled(FontAwesomeIcon)``; export const ClockIcon = styled(Clock)<{ color: string }>`
fill: ${props => props.color};
`;
export const EditorTextarea = styled(TextareaAutosize)` export const EditorTextarea = styled(TextareaAutosize)`
overflow: hidden; overflow: hidden;
@ -147,6 +147,11 @@ export const ListCardLabelText = styled.span`
line-height: 16px; line-height: 16px;
`; `;
export const ListCardLabelsWrapper = styled.div`
overflow: auto;
position: relative;
`;
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>` export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
${props => ${props =>
props.variant === 'small' props.variant === 'small'
@ -178,8 +183,6 @@ export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
`; `;
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>` export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
overflow: auto;
position: relative;
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
} }

View File

@ -1,7 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Pencil, Eye, List } from 'shared/icons';
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
import { faClock, faEye } from '@fortawesome/free-regular-svg-icons';
import { import {
EditorTextarea, EditorTextarea,
CardMember, CardMember,
@ -20,6 +18,7 @@ import {
ListCardLabels, ListCardLabels,
ListCardLabel, ListCardLabel,
ListCardLabelText, ListCardLabelText,
ListCardLabelsWrapper,
ListCardOperation, ListCardOperation,
CardTitle, CardTitle,
CardMembers, CardMembers,
@ -154,39 +153,42 @@ const Card = React.forwardRef(
} }
}} }}
> >
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} /> <Pencil width={8} height={8} />
</ListCardOperation> </ListCardOperation>
)} )}
<ListCardDetails complete={complete ?? false}> <ListCardDetails complete={complete ?? false}>
<ListCardLabels {labels && labels.length !== 0 && (
toggleLabels={toggleLabels} <ListCardLabelsWrapper>
toggleDirection={toggleDirection} <ListCardLabels
onClick={e => { toggleLabels={toggleLabels}
e.stopPropagation(); toggleDirection={toggleDirection}
if (onCardLabelClick) { onClick={e => {
onCardLabelClick(); e.stopPropagation();
} if (onCardLabelClick) {
}} onCardLabelClick();
> }
{labels && }}
labels >
.slice() {labels
.sort((a, b) => a.labelColor.position - b.labelColor.position) .slice()
.map(label => ( .sort((a, b) => a.labelColor.position - b.labelColor.position)
<ListCardLabel .map(label => (
onAnimationEnd={() => { <ListCardLabel
if (setToggleLabels) { onAnimationEnd={() => {
setToggleLabels(false); if (setToggleLabels) {
} setToggleLabels(false);
}} }
variant={labelVariant ?? 'large'} }}
color={label.labelColor.colorHex} variant={labelVariant ?? 'large'}
key={label.id} color={label.labelColor.colorHex}
> key={label.id}
<ListCardLabelText>{label.name}</ListCardLabelText> >
</ListCardLabel> <ListCardLabelText>{label.name}</ListCardLabelText>
))} </ListCardLabel>
</ListCardLabels> ))}
</ListCardLabels>
</ListCardLabelsWrapper>
)}
{editable ? ( {editable ? (
<EditorContent> <EditorContent>
{complete && <CompleteIcon width={16} height={16} />} {complete && <CompleteIcon width={16} height={16} />}
@ -214,18 +216,18 @@ const Card = React.forwardRef(
<ListCardBadges> <ListCardBadges>
{watched && ( {watched && (
<ListCardBadge> <ListCardBadge>
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" /> <Eye width={8} height={8} />
</ListCardBadge> </ListCardBadge>
)} )}
{dueDate && ( {dueDate && (
<DueDateCardBadge isPastDue={dueDate.isPastDue}> <DueDateCardBadge isPastDue={dueDate.isPastDue}>
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" /> <ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} width={8} height={8} />
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText> <ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
</DueDateCardBadge> </DueDateCardBadge>
)} )}
{description && ( {description && (
<DescriptionBadge> <DescriptionBadge>
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" /> <List width={8} height={8} />
</DescriptionBadge> </DescriptionBadge>
)} )}
{checklists && ( {checklists && (

View File

@ -1,15 +1,15 @@
import styled from 'styled-components'; import styled from 'styled-components';
import Button from 'shared/components/Button'; import Button from 'shared/components/Button';
import TextareaAutosize from 'react-autosize-textarea'; import TextareaAutosize from 'react-autosize-textarea';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
export const CancelIcon = styled(FontAwesomeIcon)` export const CancelIconWrapper = styled.div`
opacity: 0.8; opacity: 0.8;
cursor: pointer; cursor: pointer;
font-size: 1.25em; font-size: 1.25em;
padding-left: 5px; padding-left: 5px;
`; `;
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>` export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
padding-bottom: 8px; padding-bottom: 8px;
display: ${props => (props.isOpen ? 'flex' : 'none')}; display: ${props => (props.isOpen ? 'flex' : 'none')};

View File

@ -1,12 +1,12 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown'; import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import useOnOutsideClick from 'shared/hooks/onOutsideClick'; import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { Cross } from 'shared/icons';
import { import {
CardComposerWrapper, CardComposerWrapper,
CancelIcon, CancelIconWrapper,
AddCardButton, AddCardButton,
ComposerControls, ComposerControls,
ComposerControlsSaveSection, ComposerControlsSaveSection,
@ -52,7 +52,9 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
> >
Add Card Add Card
</AddCardButton> </AddCardButton>
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" /> <CancelIconWrapper onClick={() => onClose()}>
<Cross width={12} height={12} />
</CancelIconWrapper>
</ComposerControlsSaveSection> </ComposerControlsSaveSection>
<ComposerControlsActionsSection /> <ComposerControlsActionsSection />
</ComposerControls> </ComposerControls>

View File

@ -78,6 +78,7 @@ const Icon = styled.div`
type InputProps = { type InputProps = {
variant?: 'normal' | 'alternate'; variant?: 'normal' | 'alternate';
disabled?: boolean;
label?: string; label?: string;
width?: string; width?: string;
floatingLabel?: boolean; floatingLabel?: boolean;
@ -116,6 +117,7 @@ function useCombinedRefs(...refs: any) {
const Input = React.forwardRef( const Input = React.forwardRef(
( (
{ {
disabled = false,
width = 'auto', width = 'auto',
variant = 'normal', variant = 'normal',
type = 'text', type = 'text',
@ -160,6 +162,7 @@ const Input = React.forwardRef(
onChange={e => { onChange={e => {
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false); setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
}} }}
disabled={disabled}
hasValue={hasValue} hasValue={hasValue}
ref={combinedRef} ref={combinedRef}
id={id} id={id}

View File

@ -1,18 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import ListActions from '.';
export default {
component: ListActions,
title: 'ListActions',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
export const Default = () => {
return <ListActions taskGroupID="1" onArchiveTaskGroup={action('on archive task group')} />;
};

View File

@ -1,50 +1,100 @@
import React from 'react'; import React from 'react';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { NameEditor } from 'shared/components/AddList';
import NOOP from 'shared/utils/noop';
import styled from 'styled-components';
import { TaskSorting, TaskSortingDirection, TaskSortingType } from 'shared/utils/sorting';
import { InnerContent, ListActionsWrapper, ListActionItemWrapper, ListActionItem, ListSeparator } from './Styles'; import { InnerContent, ListActionsWrapper, ListActionItemWrapper, ListActionItem, ListSeparator } from './Styles';
const CopyWrapper = styled.div`
margin: 0 12px;
`;
type Props = { type Props = {
taskGroupID: string; taskGroupID: string;
onDuplicateTaskGroup: (newTaskGroupName: string) => void;
onDeleteTaskGroupTasks: () => void;
onArchiveTaskGroup: (taskGroupID: string) => void; onArchiveTaskGroup: (taskGroupID: string) => void;
onSortTaskGroup: (taskSorting: TaskSorting) => void;
}; };
const LabelManager: React.FC<Props> = ({ taskGroupID, onArchiveTaskGroup }) => {
const LabelManager: React.FC<Props> = ({
taskGroupID,
onDeleteTaskGroupTasks,
onDuplicateTaskGroup,
onArchiveTaskGroup,
onSortTaskGroup,
}) => {
const { setTab } = usePopup();
return ( return (
<InnerContent> <>
<ListActionsWrapper> <Popup tab={0} title={null}>
<ListActionItemWrapper> <InnerContent>
<ListActionItem>Add card...</ListActionItem> <ListActionsWrapper>
</ListActionItemWrapper> <ListActionItemWrapper onClick={() => setTab(1)}>
<ListActionItemWrapper> <ListActionItem>Duplicate</ListActionItem>
<ListActionItem>Copy List...</ListActionItem> </ListActionItemWrapper>
</ListActionItemWrapper> <ListActionItemWrapper onClick={() => setTab(2)}>
<ListActionItemWrapper> <ListActionItem>Sort</ListActionItem>
<ListActionItem>Move card...</ListActionItem> </ListActionItemWrapper>
</ListActionItemWrapper> </ListActionsWrapper>
<ListActionItemWrapper> <ListSeparator />
<ListActionItem>Watch</ListActionItem> <ListActionsWrapper>
</ListActionItemWrapper> <ListActionItemWrapper onClick={() => onDeleteTaskGroupTasks()}>
</ListActionsWrapper> <ListActionItem>Delete All Tasks</ListActionItem>
<ListSeparator /> </ListActionItemWrapper>
<ListActionsWrapper> </ListActionsWrapper>
<ListActionItemWrapper> <ListSeparator />
<ListActionItem>Sort By...</ListActionItem> <ListActionsWrapper>
</ListActionItemWrapper> <ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}>
</ListActionsWrapper> <ListActionItem>Delete</ListActionItem>
<ListSeparator /> </ListActionItemWrapper>
<ListActionsWrapper> </ListActionsWrapper>
<ListActionItemWrapper> </InnerContent>
<ListActionItem>Move All Cards in This List...</ListActionItem> </Popup>
</ListActionItemWrapper> <Popup tab={1} title="Copy list" onClose={NOOP}>
<ListActionItemWrapper> <CopyWrapper>
<ListActionItem>Archive All Cards in This List...</ListActionItem> <NameEditor
</ListActionItemWrapper> onCancel={NOOP}
</ListActionsWrapper> onSave={listName => {
<ListSeparator /> onDuplicateTaskGroup(listName);
<ListActionsWrapper> }}
<ListActionItemWrapper onClick={() => onArchiveTaskGroup(taskGroupID)}> buttonLabel="Duplicate"
<ListActionItem>Archive This List</ListActionItem> />
</ListActionItemWrapper> </CopyWrapper>
</ListActionsWrapper> </Popup>
</InnerContent> <Popup tab={2} title="Sort list" onClose={NOOP}>
<InnerContent>
<ListActionsWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Task title</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.TASK_TITLE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Due date</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.COMPLETE, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Complete</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.LABELS, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Labels</ListActionItem>
</ListActionItemWrapper>
<ListActionItemWrapper
onClick={() => onSortTaskGroup({ type: TaskSortingType.MEMBERS, direction: TaskSortingDirection.ASC })}
>
<ListActionItem>Members</ListActionItem>
</ListActionItemWrapper>
</ListActionsWrapper>
</InnerContent>
</Popup>
</>
); );
}; };
export default LabelManager; export default LabelManager;

View File

@ -11,6 +11,7 @@ import {
getAfterDropDraggableList, getAfterDropDraggableList,
} from 'shared/utils/draggables'; } from 'shared/utils/draggables';
import moment from 'moment'; import moment from 'moment';
import { TaskSorting, TaskSortingType, TaskSortingDirection, sortTasks } from 'shared/utils/sorting';
import { Container, BoardContainer, BoardWrapper } from './Styles'; import { Container, BoardContainer, BoardWrapper } from './Styles';
import shouldMetaFilter from './metaFilter'; import shouldMetaFilter from './metaFilter';
@ -94,127 +95,6 @@ export type TaskMetaFilters = {
labels: Array<LabelMetaFilter>; labels: Array<LabelMetaFilter>;
}; };
export enum TaskSortingType {
NONE,
DUE_DATE,
MEMBERS,
LABELS,
TASK_TITLE,
}
export enum TaskSortingDirection {
ASC,
DESC,
}
export type TaskSorting = {
type: TaskSortingType;
direction: TaskSortingDirection;
};
function sortString(a: string, b: string) {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
function sortTasks(a: Task, b: Task, taskSorting: TaskSorting) {
if (taskSorting.type === TaskSortingType.TASK_TITLE) {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.DUE_DATE) {
if (a.dueDate && !b.dueDate) {
return -1;
}
if (b.dueDate && !a.dueDate) {
return 1;
}
return moment(a.dueDate).diff(moment(b.dueDate));
}
if (taskSorting.type === TaskSortingType.LABELS) {
// sorts non-empty labels by name, then by empty label color name
let aLabels = [];
let bLabels = [];
let aLabelsEmpty = [];
let bLabelsEmpty = [];
if (a.labels) {
for (const aLabel of a.labels) {
if (aLabel.projectLabel.name && aLabel.projectLabel.name !== '') {
aLabels.push(aLabel.projectLabel.name);
} else {
aLabelsEmpty.push(aLabel.projectLabel.labelColor.name);
}
}
}
if (b.labels) {
for (const bLabel of b.labels) {
if (bLabel.projectLabel.name && bLabel.projectLabel.name !== '') {
bLabels.push(bLabel.projectLabel.name);
} else {
bLabelsEmpty.push(bLabel.projectLabel.labelColor.name);
}
}
}
aLabels = aLabels.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
bLabels = bLabels.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
aLabelsEmpty = aLabelsEmpty.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
bLabelsEmpty = bLabelsEmpty.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
if (aLabelsEmpty.length !== 0 || bLabelsEmpty.length !== 0) {
if (aLabelsEmpty.length > bLabelsEmpty.length) {
if (bLabels.length !== 0) {
return 1;
}
return -1;
}
}
if (aLabels.length < bLabels.length) {
return 1;
}
if (aLabels.length > bLabels.length) {
return -1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.MEMBERS) {
let aMembers = [];
let bMembers = [];
if (a.assigned) {
for (const aMember of a.assigned) {
if (aMember.fullName) {
aMembers.push(aMember.fullName);
}
}
}
if (b.assigned) {
for (const bMember of b.assigned) {
if (bMember.fullName) {
bMembers.push(bMember.fullName);
}
}
}
aMembers = aMembers.sort((aMember, bMember) => sortString(aMember, bMember));
bMembers = bMembers.sort((aMember, bMember) => sortString(aMember, bMember));
if (aMembers.length < bMembers.length) {
return 1;
}
if (aMembers.length > bMembers.length) {
return -1;
}
return 0;
}
return 0;
}
function shouldStatusFilter(task: Task, filter: TaskStatusFilter) { function shouldStatusFilter(task: Task, filter: TaskStatusFilter) {
if (filter.status === TaskStatus.ALL) { if (filter.status === TaskStatus.ALL) {
return true; return true;

View File

@ -27,6 +27,7 @@ export const Default = () => {
<BaseStyles /> <BaseStyles />
<Settings <Settings
profile={profile} profile={profile}
onChangeUserInfo={action('change user info')}
onResetPassword={action('reset password')} onResetPassword={action('reset password')}
onProfileAvatarRemove={action('remove')} onProfileAvatarRemove={action('remove')}
onProfileAvatarChange={action('profile avatar change')} onProfileAvatarChange={action('profile avatar change')}

View File

@ -10,6 +10,11 @@ const PasswordInput = styled(Input)`
margin-bottom: 0; margin-bottom: 0;
`; `;
const UserInfoInput = styled(Input)`
margin-top: 30px;
margin-bottom: 0;
`;
const FormError = styled.span` const FormError = styled.span`
font-size: 12px; font-size: 12px;
color: rgba(${props => props.theme.colors.warning}); color: rgba(${props => props.theme.colors.warning});
@ -240,6 +245,7 @@ const SaveButton = styled(Button)`
type SettingsProps = { type SettingsProps = {
onProfileAvatarChange: () => void; onProfileAvatarChange: () => void;
onProfileAvatarRemove: () => void; onProfileAvatarRemove: () => void;
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
onResetPassword: (password: string, done: () => void) => void; onResetPassword: (password: string, done: () => void) => void;
profile: TaskUser; profile: TaskUser;
}; };
@ -300,9 +306,93 @@ const ResetPasswordTab: React.FC<ResetPasswordTabProps> = ({ onResetPassword })
); );
}; };
type UserInfoData = {
full_name: string;
bio: string;
initials: string;
email: string;
};
type UserInfoTabProps = {
profile: TaskUser;
onProfileAvatarChange: () => void;
onProfileAvatarRemove: () => void;
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
};
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 UserInfoTab: React.FC<UserInfoTabProps> = ({
profile,
onProfileAvatarRemove,
onProfileAvatarChange,
onChangeUserInfo,
}) => {
const [active, setActive] = useState(true);
const { register, handleSubmit, errors } = useForm<UserInfoData>();
const done = () => {
setActive(true);
};
return (
<>
<AvatarSettings
onProfileAvatarRemove={onProfileAvatarRemove}
onProfileAvatarChange={onProfileAvatarChange}
profile={profile.profileIcon}
/>
<form
onSubmit={handleSubmit(data => {
setActive(false);
onChangeUserInfo(data, done);
})}
>
<UserInfoInput
ref={register({ required: 'Full name is required' })}
name="full_name"
defaultValue={profile.fullName}
width="100%"
label="Name"
/>
{errors.full_name && <FormError>{errors.full_name.message}</FormError>}
<UserInfoInput
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
ref={register({
required: 'Initials is required',
pattern: { value: INITIALS_PATTERN, message: 'Intials must be between two to four characters' },
})}
name="initials"
width="100%"
label="Initials "
/>
{errors.initials && <FormError>{errors.initials.message}</FormError>}
<UserInfoInput disabled defaultValue={profile.username ?? ''} width="100%" label="Username " />
<UserInfoInput
width="100%"
name="email"
ref={register({
required: 'Email is required',
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
})}
defaultValue={profile.email ?? ''}
label="Email"
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
<UserInfoInput width="100%" name="bio" ref={register()} defaultValue={profile.bio ?? ''} label="Bio" />
{errors.bio && <FormError>{errors.bio.message}</FormError>}
<SettingActions>
<SaveButton disabled={!active} type="submit">
Save Change
</SaveButton>
</SettingActions>
</form>
</>
);
};
const Settings: React.FC<SettingsProps> = ({ const Settings: React.FC<SettingsProps> = ({
onProfileAvatarRemove, onProfileAvatarRemove,
onProfileAvatarChange, onProfileAvatarChange,
onChangeUserInfo,
onResetPassword, onResetPassword,
profile, profile,
}) => { }) => {
@ -315,6 +405,7 @@ const Settings: React.FC<SettingsProps> = ({
<TabNavContent> <TabNavContent>
{items.map((item, idx) => ( {items.map((item, idx) => (
<NavItem <NavItem
key={item.name}
onClick={(tab, top) => { onClick={(tab, top) => {
if ($tabNav && $tabNav.current) { if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect(); const pos = $tabNav.current.getBoundingClientRect();
@ -332,23 +423,12 @@ const Settings: React.FC<SettingsProps> = ({
</TabNav> </TabNav>
<TabContentWrapper> <TabContentWrapper>
<Tab tab={0} currentTab={currentTab}> <Tab tab={0} currentTab={currentTab}>
<AvatarSettings <UserInfoTab
onProfileAvatarRemove={onProfileAvatarRemove}
onProfileAvatarChange={onProfileAvatarChange} onProfileAvatarChange={onProfileAvatarChange}
profile={profile.profileIcon} onProfileAvatarRemove={onProfileAvatarRemove}
profile={profile}
onChangeUserInfo={onChangeUserInfo}
/> />
<Input defaultValue={profile.fullName} width="100%" label="Name" />
<Input
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
width="100%"
label="Initials "
/>
<Input defaultValue={profile.username ?? ''} width="100%" label="Username " />
<Input width="100%" label="Email" />
<Input width="100%" label="Bio" />
<SettingActions>
<SaveButton>Save Change</SaveButton>
</SettingActions>
</Tab> </Tab>
<Tab tab={1} currentTab={currentTab}> <Tab tab={1} currentTab={currentTab}>
<ResetPasswordTab onResetPassword={onResetPassword} /> <ResetPasswordTab onResetPassword={onResetPassword} />

View File

@ -585,3 +585,30 @@ export const ActivityItemLog = styled.span`
margin-left: 2px; margin-left: 2px;
color: rgba(${props => props.theme.colors.text.primary}); color: rgba(${props => props.theme.colors.text.primary});
`; `;
export const ViewRawButton = styled.button`
border-radius: 3px;
padding: 8px 12px;
display: flex;
position: absolute;
right: 4px;
bottom: -24px;
cursor: pointer;
color: rgba(${props => props.theme.colors.text.primary}, 0.25);
&:hover {
color: rgba(${props => props.theme.colors.text.primary});
}
`;
export const TaskDetailsEditor = styled(TextareaAutosize)`
min-height: 108px;
color: #c2c6dc;
background: #262c49;
border-radius: 3px;
line-height: 20px;
margin-left: 32px;
margin-right: 32px;
padding: 9px 8px 7px 8px;
outline: none;
border: none;
`;

View File

@ -30,6 +30,7 @@ import {
AssignUserLabel, AssignUserLabel,
AssignUsersButton, AssignUsersButton,
AssignedUsersSection, AssignedUsersSection,
ViewRawButton,
DueDateTitle, DueDateTitle,
Container, Container,
LeftSidebar, LeftSidebar,
@ -65,6 +66,7 @@ import {
CommentProfile, CommentProfile,
CommentInnerWrapper, CommentInnerWrapper,
ActivitySection, ActivitySection,
TaskDetailsEditor,
} from './Styles'; } from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist'; import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd'; import onDragEnd from './onDragEnd';
@ -153,6 +155,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
return true; return true;
}); });
const [saveTimeout, setSaveTimeout] = useState<any>(null); const [saveTimeout, setSaveTimeout] = useState<any>(null);
const [showRaw, setShowRaw] = useState(false);
const [showCommentActions, setShowCommentActions] = useState(false); const [showCommentActions, setShowCommentActions] = useState(false);
const taskDescriptionRef = useRef(task.description ?? ''); const taskDescriptionRef = useRef(task.description ?? '');
const $noMemberBtn = useRef<HTMLDivElement>(null); const $noMemberBtn = useRef<HTMLDivElement>(null);
@ -169,7 +172,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<LeftSidebarSection> <LeftSidebarSection>
<SidebarTitle>TASK GROUP</SidebarTitle> <SidebarTitle>TASK GROUP</SidebarTitle>
<SidebarButton> <SidebarButton>
<SidebarButtonText>Release 0.1.0</SidebarButtonText> <SidebarButtonText>{task.taskGroup.name}</SidebarButtonText>
</SidebarButton> </SidebarButton>
<DueDateTitle>DUE DATE</DueDateTitle> <DueDateTitle>DUE DATE</DueDateTitle>
<SidebarButton <SidebarButton
@ -309,28 +312,34 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
</HeaderContainer> </HeaderContainer>
<InnerContentContainer> <InnerContentContainer>
<DescriptionContainer> <DescriptionContainer>
<EditorContainer {showRaw ? (
onClick={e => { <TaskDetailsEditor value={taskDescriptionRef.current} />
if (!editTaskDescription) { ) : (
setEditTaskDescription(true); <EditorContainer
} onClick={e => {
}} if (!editTaskDescription) {
> setEditTaskDescription(true);
<Editor }
defaultValue={task.description ?? ''}
theme={dark}
readOnly={!editTaskDescription}
autoFocus
onChange={value => {
setSaveTimeout(() => {
clearTimeout(saveTimeout);
return setTimeout(saveDescription, 2000);
});
const text = value();
taskDescriptionRef.current = text;
}} }}
/> >
</EditorContainer> <Editor
defaultValue={task.description ?? ''}
theme={dark}
readOnly={!editTaskDescription}
autoFocus
onChange={value => {
setSaveTimeout(() => {
clearTimeout(saveTimeout);
return setTimeout(saveDescription, 2000);
});
const text = value();
taskDescriptionRef.current = text;
}}
/>
</EditorContainer>
)}
<ViewRawButton onClick={() => setShowRaw(!showRaw)}>{showRaw ? 'Show editor' : 'Show raw'}</ViewRawButton>
</DescriptionContainer> </DescriptionContainer>
<ChecklistSection> <ChecklistSection>
<DragDropContext onDragEnd={result => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}> <DragDropContext onDragEnd={result => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>

View File

@ -251,8 +251,8 @@ export const NavSeparator = styled.div`
export const LogoContainer = styled(Link)` export const LogoContainer = styled(Link)`
display: block; display: block;
left: 50%; left: 50%;
right: 50%;
position: absolute; position: absolute;
transform: translateX(-50%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -105,6 +105,7 @@ export type UserAccount = {
createdAt: Scalars['Time']; createdAt: Scalars['Time'];
fullName: Scalars['String']; fullName: Scalars['String'];
initials: Scalars['String']; initials: Scalars['String'];
bio: Scalars['String'];
role: Role; role: Role;
username: Scalars['String']; username: Scalars['String'];
profileIcon: ProfileIcon; profileIcon: ProfileIcon;
@ -275,13 +276,16 @@ export type Mutation = {
deleteTaskChecklist: DeleteTaskChecklistPayload; deleteTaskChecklist: DeleteTaskChecklistPayload;
deleteTaskChecklistItem: DeleteTaskChecklistItemPayload; deleteTaskChecklistItem: DeleteTaskChecklistItemPayload;
deleteTaskGroup: DeleteTaskGroupPayload; deleteTaskGroup: DeleteTaskGroupPayload;
deleteTaskGroupTasks: DeleteTaskGroupTasksPayload;
deleteTeam: DeleteTeamPayload; deleteTeam: DeleteTeamPayload;
deleteTeamMember: DeleteTeamMemberPayload; deleteTeamMember: DeleteTeamMemberPayload;
deleteUserAccount: DeleteUserAccountPayload; deleteUserAccount: DeleteUserAccountPayload;
duplicateTaskGroup: DuplicateTaskGroupPayload;
logoutUser: Scalars['Boolean']; logoutUser: Scalars['Boolean'];
removeTaskLabel: Task; removeTaskLabel: Task;
setTaskChecklistItemComplete: TaskChecklistItem; setTaskChecklistItemComplete: TaskChecklistItem;
setTaskComplete: Task; setTaskComplete: Task;
sortTaskGroup: SortTaskGroupPayload;
toggleTaskLabel: ToggleTaskLabelPayload; toggleTaskLabel: ToggleTaskLabelPayload;
unassignTask: Task; unassignTask: Task;
updateProjectLabel: ProjectLabel; updateProjectLabel: ProjectLabel;
@ -300,6 +304,7 @@ export type Mutation = {
updateTaskLocation: UpdateTaskLocationPayload; updateTaskLocation: UpdateTaskLocationPayload;
updateTaskName: Task; updateTaskName: Task;
updateTeamMemberRole: UpdateTeamMemberRolePayload; updateTeamMemberRole: UpdateTeamMemberRolePayload;
updateUserInfo: UpdateUserInfoPayload;
updateUserPassword: UpdateUserPasswordPayload; updateUserPassword: UpdateUserPasswordPayload;
updateUserRole: UpdateUserRolePayload; updateUserRole: UpdateUserRolePayload;
}; };
@ -405,6 +410,11 @@ export type MutationDeleteTaskGroupArgs = {
}; };
export type MutationDeleteTaskGroupTasksArgs = {
input: DeleteTaskGroupTasks;
};
export type MutationDeleteTeamArgs = { export type MutationDeleteTeamArgs = {
input: DeleteTeam; input: DeleteTeam;
}; };
@ -420,6 +430,11 @@ export type MutationDeleteUserAccountArgs = {
}; };
export type MutationDuplicateTaskGroupArgs = {
input: DuplicateTaskGroup;
};
export type MutationLogoutUserArgs = { export type MutationLogoutUserArgs = {
input: LogoutUser; input: LogoutUser;
}; };
@ -440,6 +455,11 @@ export type MutationSetTaskCompleteArgs = {
}; };
export type MutationSortTaskGroupArgs = {
input: SortTaskGroup;
};
export type MutationToggleTaskLabelArgs = { export type MutationToggleTaskLabelArgs = {
input: ToggleTaskLabelInput; input: ToggleTaskLabelInput;
}; };
@ -530,6 +550,11 @@ export type MutationUpdateTeamMemberRoleArgs = {
}; };
export type MutationUpdateUserInfoArgs = {
input: UpdateUserInfo;
};
export type MutationUpdateUserPasswordArgs = { export type MutationUpdateUserPasswordArgs = {
input: UpdateUserPassword; input: UpdateUserPassword;
}; };
@ -823,6 +848,44 @@ export type DeleteTaskChecklistPayload = {
taskChecklist: TaskChecklist; taskChecklist: TaskChecklist;
}; };
export type DeleteTaskGroupTasks = {
taskGroupID: Scalars['UUID'];
};
export type DeleteTaskGroupTasksPayload = {
__typename?: 'DeleteTaskGroupTasksPayload';
taskGroupID: Scalars['UUID'];
tasks: Array<Scalars['UUID']>;
};
export type TaskPositionUpdate = {
taskID: Scalars['UUID'];
position: Scalars['Float'];
};
export type SortTaskGroupPayload = {
__typename?: 'SortTaskGroupPayload';
taskGroupID: Scalars['UUID'];
tasks: Array<Task>;
};
export type SortTaskGroup = {
taskGroupID: Scalars['UUID'];
tasks: Array<TaskPositionUpdate>;
};
export type DuplicateTaskGroup = {
projectID: Scalars['UUID'];
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
};
export type DuplicateTaskGroupPayload = {
__typename?: 'DuplicateTaskGroupPayload';
taskGroup: TaskGroup;
};
export type NewTaskGroupLocation = { export type NewTaskGroupLocation = {
taskGroupID: Scalars['UUID']; taskGroupID: Scalars['UUID'];
position: Scalars['Float']; position: Scalars['Float'];
@ -923,6 +986,18 @@ export type UpdateTeamMemberRolePayload = {
member: Member; member: Member;
}; };
export type UpdateUserInfoPayload = {
__typename?: 'UpdateUserInfoPayload';
user: UserAccount;
};
export type UpdateUserInfo = {
name: Scalars['String'];
initials: Scalars['String'];
email: Scalars['String'];
bio: Scalars['String'];
};
export type UpdateUserPassword = { export type UpdateUserPassword = {
userID: Scalars['UUID']; userID: Scalars['UUID'];
password: Scalars['String']; password: Scalars['String'];
@ -1189,7 +1264,7 @@ export type FindTaskQuery = (
& Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'position' | 'complete'> & Pick<Task, 'id' | 'name' | 'description' | 'dueDate' | 'position' | 'complete'>
& { taskGroup: ( & { taskGroup: (
{ __typename?: 'TaskGroup' } { __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id'> & Pick<TaskGroup, 'id' | 'name'>
), badges: ( ), badges: (
{ __typename?: 'TaskBadges' } { __typename?: 'TaskBadges' }
& { checklist?: Maybe<( & { checklist?: Maybe<(
@ -1298,7 +1373,7 @@ export type MeQuery = (
{ __typename?: 'MePayload' } { __typename?: 'MePayload' }
& { user: ( & { user: (
{ __typename?: 'UserAccount' } { __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'fullName'> & Pick<UserAccount, 'id' | 'fullName' | 'username' | 'email' | 'bio'>
& { profileIcon: ( & { profileIcon: (
{ __typename?: 'ProfileIcon' } { __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'> & Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
@ -1575,6 +1650,60 @@ export type UpdateTaskChecklistNameMutation = (
) } ) }
); );
export type DeleteTaskGroupTasksMutationVariables = {
taskGroupID: Scalars['UUID'];
};
export type DeleteTaskGroupTasksMutation = (
{ __typename?: 'Mutation' }
& { deleteTaskGroupTasks: (
{ __typename?: 'DeleteTaskGroupTasksPayload' }
& Pick<DeleteTaskGroupTasksPayload, 'tasks' | 'taskGroupID'>
) }
);
export type DuplicateTaskGroupMutationVariables = {
taskGroupID: Scalars['UUID'];
name: Scalars['String'];
position: Scalars['Float'];
projectID: Scalars['UUID'];
};
export type DuplicateTaskGroupMutation = (
{ __typename?: 'Mutation' }
& { duplicateTaskGroup: (
{ __typename?: 'DuplicateTaskGroupPayload' }
& { taskGroup: (
{ __typename?: 'TaskGroup' }
& Pick<TaskGroup, 'id' | 'name' | 'position'>
& { tasks: Array<(
{ __typename?: 'Task' }
& TaskFieldsFragment
)> }
) }
) }
);
export type SortTaskGroupMutationVariables = {
tasks: Array<TaskPositionUpdate>;
taskGroupID: Scalars['UUID'];
};
export type SortTaskGroupMutation = (
{ __typename?: 'Mutation' }
& { sortTaskGroup: (
{ __typename?: 'SortTaskGroupPayload' }
& Pick<SortTaskGroupPayload, 'taskGroupID'>
& { tasks: Array<(
{ __typename?: 'Task' }
& Pick<Task, 'id' | 'position'>
)> }
) }
);
export type UpdateTaskGroupNameMutationVariables = { export type UpdateTaskGroupNameMutationVariables = {
taskGroupID: Scalars['UUID']; taskGroupID: Scalars['UUID'];
name: Scalars['String']; name: Scalars['String'];
@ -1970,7 +2099,7 @@ export type CreateUserAccountMutation = (
{ __typename?: 'Mutation' } { __typename?: 'Mutation' }
& { createUserAccount: ( & { createUserAccount: (
{ __typename?: 'UserAccount' } { __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'initials' | 'username'> & Pick<UserAccount, 'id' | 'email' | 'fullName' | 'initials' | 'username' | 'bio'>
& { profileIcon: ( & { profileIcon: (
{ __typename?: 'ProfileIcon' } { __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'> & Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
@ -2017,6 +2146,29 @@ export type DeleteUserAccountMutation = (
) } ) }
); );
export type UpdateUserInfoMutationVariables = {
name: Scalars['String'];
initials: Scalars['String'];
email: Scalars['String'];
bio: Scalars['String'];
};
export type UpdateUserInfoMutation = (
{ __typename?: 'Mutation' }
& { updateUserInfo: (
{ __typename?: 'UpdateUserInfoPayload' }
& { user: (
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'bio'>
& { profileIcon: (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'initials'>
) }
) }
) }
);
export type UpdateUserPasswordMutationVariables = { export type UpdateUserPasswordMutationVariables = {
userID: Scalars['UUID']; userID: Scalars['UUID'];
password: Scalars['String']; password: Scalars['String'];
@ -2550,6 +2702,7 @@ export const FindTaskDocument = gql`
complete complete
taskGroup { taskGroup {
id id
name
} }
badges { badges {
checklist { checklist {
@ -2685,6 +2838,9 @@ export const MeDocument = gql`
user { user {
id id
fullName fullName
username
email
bio
profileIcon { profileIcon {
initials initials
bgColor bgColor
@ -3292,6 +3448,118 @@ export function useUpdateTaskChecklistNameMutation(baseOptions?: ApolloReactHook
export type UpdateTaskChecklistNameMutationHookResult = ReturnType<typeof useUpdateTaskChecklistNameMutation>; export type UpdateTaskChecklistNameMutationHookResult = ReturnType<typeof useUpdateTaskChecklistNameMutation>;
export type UpdateTaskChecklistNameMutationResult = ApolloReactCommon.MutationResult<UpdateTaskChecklistNameMutation>; export type UpdateTaskChecklistNameMutationResult = ApolloReactCommon.MutationResult<UpdateTaskChecklistNameMutation>;
export type UpdateTaskChecklistNameMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateTaskChecklistNameMutation, UpdateTaskChecklistNameMutationVariables>; export type UpdateTaskChecklistNameMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateTaskChecklistNameMutation, UpdateTaskChecklistNameMutationVariables>;
export const DeleteTaskGroupTasksDocument = gql`
mutation deleteTaskGroupTasks($taskGroupID: UUID!) {
deleteTaskGroupTasks(input: {taskGroupID: $taskGroupID}) {
tasks
taskGroupID
}
}
`;
export type DeleteTaskGroupTasksMutationFn = ApolloReactCommon.MutationFunction<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>;
/**
* __useDeleteTaskGroupTasksMutation__
*
* To run a mutation, you first call `useDeleteTaskGroupTasksMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteTaskGroupTasksMutation` 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 [deleteTaskGroupTasksMutation, { data, loading, error }] = useDeleteTaskGroupTasksMutation({
* variables: {
* taskGroupID: // value for 'taskGroupID'
* },
* });
*/
export function useDeleteTaskGroupTasksMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>) {
return ApolloReactHooks.useMutation<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>(DeleteTaskGroupTasksDocument, baseOptions);
}
export type DeleteTaskGroupTasksMutationHookResult = ReturnType<typeof useDeleteTaskGroupTasksMutation>;
export type DeleteTaskGroupTasksMutationResult = ApolloReactCommon.MutationResult<DeleteTaskGroupTasksMutation>;
export type DeleteTaskGroupTasksMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteTaskGroupTasksMutation, DeleteTaskGroupTasksMutationVariables>;
export const DuplicateTaskGroupDocument = gql`
mutation duplicateTaskGroup($taskGroupID: UUID!, $name: String!, $position: Float!, $projectID: UUID!) {
duplicateTaskGroup(input: {projectID: $projectID, taskGroupID: $taskGroupID, name: $name, position: $position}) {
taskGroup {
id
name
position
tasks {
...TaskFields
}
}
}
}
${TaskFieldsFragmentDoc}`;
export type DuplicateTaskGroupMutationFn = ApolloReactCommon.MutationFunction<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>;
/**
* __useDuplicateTaskGroupMutation__
*
* To run a mutation, you first call `useDuplicateTaskGroupMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDuplicateTaskGroupMutation` 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 [duplicateTaskGroupMutation, { data, loading, error }] = useDuplicateTaskGroupMutation({
* variables: {
* taskGroupID: // value for 'taskGroupID'
* name: // value for 'name'
* position: // value for 'position'
* projectID: // value for 'projectID'
* },
* });
*/
export function useDuplicateTaskGroupMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>) {
return ApolloReactHooks.useMutation<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>(DuplicateTaskGroupDocument, baseOptions);
}
export type DuplicateTaskGroupMutationHookResult = ReturnType<typeof useDuplicateTaskGroupMutation>;
export type DuplicateTaskGroupMutationResult = ApolloReactCommon.MutationResult<DuplicateTaskGroupMutation>;
export type DuplicateTaskGroupMutationOptions = ApolloReactCommon.BaseMutationOptions<DuplicateTaskGroupMutation, DuplicateTaskGroupMutationVariables>;
export const SortTaskGroupDocument = gql`
mutation sortTaskGroup($tasks: [TaskPositionUpdate!]!, $taskGroupID: UUID!) {
sortTaskGroup(input: {taskGroupID: $taskGroupID, tasks: $tasks}) {
taskGroupID
tasks {
id
position
}
}
}
`;
export type SortTaskGroupMutationFn = ApolloReactCommon.MutationFunction<SortTaskGroupMutation, SortTaskGroupMutationVariables>;
/**
* __useSortTaskGroupMutation__
*
* To run a mutation, you first call `useSortTaskGroupMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSortTaskGroupMutation` 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 [sortTaskGroupMutation, { data, loading, error }] = useSortTaskGroupMutation({
* variables: {
* tasks: // value for 'tasks'
* taskGroupID: // value for 'taskGroupID'
* },
* });
*/
export function useSortTaskGroupMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<SortTaskGroupMutation, SortTaskGroupMutationVariables>) {
return ApolloReactHooks.useMutation<SortTaskGroupMutation, SortTaskGroupMutationVariables>(SortTaskGroupDocument, baseOptions);
}
export type SortTaskGroupMutationHookResult = ReturnType<typeof useSortTaskGroupMutation>;
export type SortTaskGroupMutationResult = ApolloReactCommon.MutationResult<SortTaskGroupMutation>;
export type SortTaskGroupMutationOptions = ApolloReactCommon.BaseMutationOptions<SortTaskGroupMutation, SortTaskGroupMutationVariables>;
export const UpdateTaskGroupNameDocument = gql` export const UpdateTaskGroupNameDocument = gql`
mutation updateTaskGroupName($taskGroupID: UUID!, $name: String!) { mutation updateTaskGroupName($taskGroupID: UUID!, $name: String!) {
updateTaskGroupName(input: {taskGroupID: $taskGroupID, name: $name}) { updateTaskGroupName(input: {taskGroupID: $taskGroupID, name: $name}) {
@ -4049,6 +4317,7 @@ export const CreateUserAccountDocument = gql`
fullName fullName
initials initials
username username
bio
profileIcon { profileIcon {
url url
initials initials
@ -4147,6 +4416,49 @@ export function useDeleteUserAccountMutation(baseOptions?: ApolloReactHooks.Muta
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>; export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
export type DeleteUserAccountMutationResult = ApolloReactCommon.MutationResult<DeleteUserAccountMutation>; export type DeleteUserAccountMutationResult = ApolloReactCommon.MutationResult<DeleteUserAccountMutation>;
export type DeleteUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>; export type DeleteUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
export const UpdateUserInfoDocument = gql`
mutation updateUserInfo($name: String!, $initials: String!, $email: String!, $bio: String!) {
updateUserInfo(input: {name: $name, initials: $initials, email: $email, bio: $bio}) {
user {
id
email
fullName
bio
profileIcon {
initials
}
}
}
}
`;
export type UpdateUserInfoMutationFn = ApolloReactCommon.MutationFunction<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>;
/**
* __useUpdateUserInfoMutation__
*
* To run a mutation, you first call `useUpdateUserInfoMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateUserInfoMutation` 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 [updateUserInfoMutation, { data, loading, error }] = useUpdateUserInfoMutation({
* variables: {
* name: // value for 'name'
* initials: // value for 'initials'
* email: // value for 'email'
* bio: // value for 'bio'
* },
* });
*/
export function useUpdateUserInfoMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>) {
return ApolloReactHooks.useMutation<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>(UpdateUserInfoDocument, baseOptions);
}
export type UpdateUserInfoMutationHookResult = ReturnType<typeof useUpdateUserInfoMutation>;
export type UpdateUserInfoMutationResult = ApolloReactCommon.MutationResult<UpdateUserInfoMutation>;
export type UpdateUserInfoMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>;
export const UpdateUserPasswordDocument = gql` export const UpdateUserPasswordDocument = gql`
mutation updateUserPassword($userID: UUID!, $password: String!) { mutation updateUserPassword($userID: UUID!, $password: String!) {
updateUserPassword(input: {userID: $userID, password: $password}) { updateUserPassword(input: {userID: $userID, password: $password}) {

View File

@ -8,6 +8,7 @@ query findTask($taskID: UUID!) {
complete complete
taskGroup { taskGroup {
id id
name
} }
badges { badges {
checklist { checklist {

View File

@ -3,6 +3,9 @@ query me {
user { user {
id id
fullName fullName
username
email
bio
profileIcon { profileIcon {
initials initials
bgColor bgColor

View File

@ -0,0 +1,10 @@
import gql from 'graphql-tag';
const DELETE_TASK_GROUP_TASKS_MUTATION = gql`
mutation deleteTaskGroupTasks($taskGroupID: UUID!) {
deleteTaskGroupTasks(input: { taskGroupID: $taskGroupID }) {
tasks
taskGroupID
}
}
`;

View File

@ -0,0 +1,26 @@
import gql from 'graphql-tag';
import TASK_FRAGMENT from '../fragments/task';
const DUPLICATE_TASK_GROUP_MUTATION = gql`
mutation duplicateTaskGroup($taskGroupID: UUID!, $name: String!, $position: Float!, $projectID: UUID!) {
duplicateTaskGroup(
input: {
projectID: $projectID
taskGroupID: $taskGroupID
name: $name
position: $position
}
) {
taskGroup {
id
name
position
tasks {
...TaskFields
}
}
}
${TASK_FRAGMENT}
}
`;

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
const SORT_TASK_GROUP_MUTATION = gql`
mutation sortTaskGroup($tasks: [TaskPositionUpdate!]!, $taskGroupID: UUID!) {
sortTaskGroup(input: { taskGroupID: $taskGroupID, tasks: $tasks }) {
taskGroupID
tasks {
id
position
}
}
}
`;

View File

@ -24,6 +24,7 @@ export const CREATE_USER_MUTATION = gql`
fullName fullName
initials initials
username username
bio
profileIcon { profileIcon {
url url
initials initials

View File

@ -0,0 +1,19 @@
import gql from 'graphql-tag';
export const UPDATE_USER_INFO_MUTATION = gql`
mutation updateUserInfo($name: String!, $initials: String!, $email: String!, $bio: String!) {
updateUserInfo(input: { name: $name, initials: $initials, email: $email, bio: $bio }) {
user {
id
email
fullName
bio
profileIcon {
initials
}
}
}
}
`;
export default UPDATE_USER_INFO_MUTATION;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Eye: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 576 512">
<path d="M288 144a110.94 110.94 0 0 0-31.24 5 55.4 55.4 0 0 1 7.24 27 56 56 0 0 1-56 56 55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144zm284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400z" />
</Icon>
);
};
export default Eye;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const List: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M80 368H16a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-64a16 16 0 0 0-16-16zm0-320H16A16 16 0 0 0 0 64v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16zm0 160H16a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-64a16 16 0 0 0-16-16zm416 176H176a16 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 List;

View File

@ -1,5 +1,7 @@
import Cross from './Cross'; import Cross from './Cross';
import Cog from './Cog'; import Cog from './Cog';
import Eye from './Eye';
import List from './List';
import At from './At'; import At from './At';
import Task from './Task'; import Task from './Task';
import Smile from './Smile'; import Smile from './Smile';
@ -85,4 +87,6 @@ export {
Clone, Clone,
Paperclip, Paperclip,
Share, Share,
Eye,
List,
}; };

View File

@ -1,6 +1,7 @@
let accessToken = ''; let accessToken = '';
export function setAccessToken(newToken: string) { export function setAccessToken(newToken: string) {
console.log(newToken);
accessToken = newToken; accessToken = newToken;
} }
export function getAccessToken() { export function getAccessToken() {

View File

@ -0,0 +1,132 @@
import moment from 'moment';
export enum TaskSortingType {
NONE,
COMPLETE,
DUE_DATE,
MEMBERS,
LABELS,
TASK_TITLE,
}
export enum TaskSortingDirection {
ASC,
DESC,
}
export type TaskSorting = {
type: TaskSortingType;
direction: TaskSortingDirection;
};
export function sortString(a: string, b: string) {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
export function sortTasks(a: Task, b: Task, taskSorting: TaskSorting) {
if (taskSorting.type === TaskSortingType.TASK_TITLE) {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.DUE_DATE) {
if (a.dueDate && !b.dueDate) {
return -1;
}
if (b.dueDate && !a.dueDate) {
return 1;
}
return moment(a.dueDate).diff(moment(b.dueDate));
}
if (taskSorting.type === TaskSortingType.COMPLETE) {
if (a.complete && !b.complete) {
return -1;
}
if (b.complete && !a.complete) {
return 1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.LABELS) {
// sorts non-empty labels by name, then by empty label color name
let aLabels = [];
let bLabels = [];
let aLabelsEmpty = [];
let bLabelsEmpty = [];
if (a.labels) {
for (const aLabel of a.labels) {
if (aLabel.projectLabel.name && aLabel.projectLabel.name !== '') {
aLabels.push(aLabel.projectLabel.name);
} else {
aLabelsEmpty.push(aLabel.projectLabel.labelColor.name);
}
}
}
if (b.labels) {
for (const bLabel of b.labels) {
if (bLabel.projectLabel.name && bLabel.projectLabel.name !== '') {
bLabels.push(bLabel.projectLabel.name);
} else {
bLabelsEmpty.push(bLabel.projectLabel.labelColor.name);
}
}
}
aLabels = aLabels.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
bLabels = bLabels.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
aLabelsEmpty = aLabelsEmpty.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
bLabelsEmpty = bLabelsEmpty.sort((aLabel, bLabel) => sortString(aLabel, bLabel));
if (aLabelsEmpty.length !== 0 || bLabelsEmpty.length !== 0) {
if (aLabelsEmpty.length > bLabelsEmpty.length) {
if (bLabels.length !== 0) {
return 1;
}
return -1;
}
}
if (aLabels.length < bLabels.length) {
return 1;
}
if (aLabels.length > bLabels.length) {
return -1;
}
return 0;
}
if (taskSorting.type === TaskSortingType.MEMBERS) {
let aMembers = [];
let bMembers = [];
if (a.assigned) {
for (const aMember of a.assigned) {
if (aMember.fullName) {
aMembers.push(aMember.fullName);
}
}
}
if (b.assigned) {
for (const bMember of b.assigned) {
if (bMember.fullName) {
bMembers.push(bMember.fullName);
}
}
}
aMembers = aMembers.sort((aMember, bMember) => sortString(aMember, bMember));
bMembers = bMembers.sort((aMember, bMember) => sortString(aMember, bMember));
if (aMembers.length < bMembers.length) {
return 1;
}
if (aMembers.length > bMembers.length) {
return -1;
}
return 0;
}
return 0;
}

View File

@ -46,6 +46,8 @@ type OwnedList = {
type TaskUser = { type TaskUser = {
id: string; id: string;
fullName: string; fullName: string;
email?: string;
bio?: string;
profileIcon: ProfileIcon; profileIcon: ProfileIcon;
username?: string; username?: string;
role?: Role; role?: Role;

View File

@ -7,8 +7,6 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
var jwtKey = []byte("taskcafe_test_key")
// RestrictedMode is used restrict JWT access to just the install route // RestrictedMode is used restrict JWT access to just the install route
type RestrictedMode string type RestrictedMode string
@ -54,7 +52,7 @@ func (r *ErrMalformedToken) Error() string {
} }
// NewAccessToken generates a new JWT access token with the correct claims // NewAccessToken generates a new JWT access token with the correct claims
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string) (string, error) { func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string, jwtKey []byte) (string, error) {
role := RoleMember role := RoleMember
if orgRole == "admin" { if orgRole == "admin" {
role = RoleAdmin role = RoleAdmin
@ -76,7 +74,7 @@ func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string
} }
// NewAccessTokenCustomExpiration creates an access token with a custom duration // NewAccessTokenCustomExpiration creates an access token with a custom duration
func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, error) { func NewAccessTokenCustomExpiration(userID string, dur time.Duration, jwtKey []byte) (string, error) {
accessExpirationTime := time.Now().Add(dur) accessExpirationTime := time.Now().Add(dur)
accessClaims := &AccessTokenClaims{ accessClaims := &AccessTokenClaims{
UserID: userID, UserID: userID,
@ -94,7 +92,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
} }
// ValidateAccessToken validates a JWT access token and returns the contained claims or an error if it's invalid // ValidateAccessToken validates a JWT access token and returns the contained claims or an error if it's invalid
func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) { func ValidateAccessToken(accessTokenString string, jwtKey []byte) (AccessTokenClaims, error) {
accessClaims := &AccessTokenClaims{} accessClaims := &AccessTokenClaims{}
accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) { accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil return jwtKey, nil

View File

@ -1,12 +1,15 @@
package commands package commands
import ( import (
"errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/jordanknott/taskcafe/internal/auth" "github.com/jordanknott/taskcafe/internal/auth"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
func newTokenCmd() *cobra.Command { func newTokenCmd() *cobra.Command {
@ -15,13 +18,18 @@ func newTokenCmd() *cobra.Command {
Short: "Create a long lived JWT token for dev purposes", Short: "Create a long lived JWT token for dev purposes",
Long: "Create a long lived JWT token for dev purposes", Long: "Create a long lived JWT token for dev purposes",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) { RunE: func(cmd *cobra.Command, args []string) error {
token, err := auth.NewAccessTokenCustomExpiration(args[0], time.Hour*24) secret := viper.GetString("server.secret")
if strings.TrimSpace(secret) == "" {
return errors.New("server.secret must be set (TASKCAFE_SERVER_SECRET)")
}
token, err := auth.NewAccessTokenCustomExpiration(args[0], time.Hour*24, []byte(secret))
if err != nil { if err != nil {
log.WithError(err).Error("issue while creating access token") log.WithError(err).Error("issue while creating access token")
return return err
} }
fmt.Println(token) fmt.Println(token)
return nil
}, },
} }
} }

View File

@ -3,11 +3,13 @@ package commands
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/httpfs" "github.com/golang-migrate/migrate/v4/source/httpfs"
"github.com/google/uuid"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -38,16 +40,21 @@ func newWebCmd() *cobra.Command {
) )
var db *sqlx.DB var db *sqlx.DB
var err error var err error
retryNumber := 0 var retryDuration time.Duration
for i := 0; retryNumber <= 3; i++ { maxRetryNumber := 4
retryNumber++ for i := 0; i < maxRetryNumber; i++ {
db, err = sqlx.Connect("postgres", connection) db, err = sqlx.Connect("postgres", connection)
if err == nil { if err == nil {
break break
} }
retryDuration := time.Duration(i*2) * time.Second retryDuration = time.Duration(i*2) * time.Second
log.WithFields(log.Fields{"retryNumber": retryNumber, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying") log.WithFields(log.Fields{"retryNumber": i, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying")
time.Sleep(retryDuration) if i != maxRetryNumber-1 {
time.Sleep(retryDuration)
}
}
if err != nil {
return err
} }
db.SetMaxOpenConns(25) db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25) db.SetMaxIdleConns(25)
@ -62,7 +69,12 @@ func newWebCmd() *cobra.Command {
} }
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server") log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
r, _ := route.NewRouter(db) secret := viper.GetString("server.secret")
if strings.TrimSpace(secret) == "" {
log.Warn("server.secret is not set, generating a random secret")
secret = uuid.New().String()
}
r, _ := route.NewRouter(db, []byte(secret))
http.ListenAndServe(viper.GetString("server.hostname"), r) http.ListenAndServe(viper.GetString("server.hostname"), r)
return nil return nil
}, },

View File

@ -157,4 +157,5 @@ type UserAccount struct {
Initials string `json:"initials"` Initials string `json:"initials"`
ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"` ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"`
RoleCode string `json:"role_code"` RoleCode string `json:"role_code"`
Bio string `json:"bio"`
} }

View File

@ -19,6 +19,7 @@ type Querier interface {
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error) CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
CreateTaskAll(ctx context.Context, arg CreateTaskAllParams) (Task, error)
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error) CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error) CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
CreateTaskChecklistItem(ctx context.Context, arg CreateTaskChecklistItemParams) (TaskChecklistItem, error) CreateTaskChecklistItem(ctx context.Context, arg CreateTaskChecklistItemParams) (TaskChecklistItem, error)
@ -111,7 +112,9 @@ type Querier interface {
UpdateTaskGroupLocation(ctx context.Context, arg UpdateTaskGroupLocationParams) (TaskGroup, error) UpdateTaskGroupLocation(ctx context.Context, arg UpdateTaskGroupLocationParams) (TaskGroup, error)
UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error) UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error)
UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error)
UpdateTaskPosition(ctx context.Context, arg UpdateTaskPositionParams) (Task, error)
UpdateTeamMemberRole(ctx context.Context, arg UpdateTeamMemberRoleParams) (TeamMember, error) UpdateTeamMemberRole(ctx context.Context, arg UpdateTeamMemberRoleParams) (TeamMember, error)
UpdateUserAccountInfo(ctx context.Context, arg UpdateUserAccountInfoParams) (UserAccount, error)
UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error) UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error)
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UserAccount, error) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UserAccount, error)
} }

View File

@ -2,6 +2,10 @@
INSERT INTO task (task_group_id, created_at, name, position) INSERT INTO task (task_group_id, created_at, name, position)
VALUES($1, $2, $3, $4) RETURNING *; VALUES($1, $2, $3, $4) RETURNING *;
-- name: CreateTaskAll :one
INSERT INTO task (task_group_id, created_at, name, position, description, complete, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *;
-- name: UpdateTaskDescription :one -- name: UpdateTaskDescription :one
UPDATE task SET description = $2 WHERE task_id = $1 RETURNING *; UPDATE task SET description = $2 WHERE task_id = $1 RETURNING *;
@ -17,6 +21,9 @@ SELECT * FROM task;
-- name: UpdateTaskLocation :one -- name: UpdateTaskLocation :one
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING *; UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING *;
-- name: UpdateTaskPosition :one
UPDATE task SET position = $2 WHERE task_id = $1 RETURNING *;
-- name: DeleteTaskByID :exec -- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1; DELETE FROM task WHERE task_id = $1;

View File

@ -15,6 +15,10 @@ INSERT INTO user_account(full_name, initials, email, username, created_at, passw
UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1 UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1
RETURNING *; RETURNING *;
-- name: UpdateUserAccountInfo :one
UPDATE user_account SET bio = $2, full_name = $3, initials = $4, email = $5
WHERE user_id = $1 RETURNING *;
-- name: DeleteUserAccountByID :exec -- name: DeleteUserAccountByID :exec
DELETE FROM user_account WHERE user_id = $1; DELETE FROM user_account WHERE user_id = $1;

View File

@ -45,6 +45,46 @@ func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, e
return i, err return i, err
} }
const createTaskAll = `-- name: CreateTaskAll :one
INSERT INTO task (task_group_id, created_at, name, position, description, complete, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
`
type CreateTaskAllParams struct {
TaskGroupID uuid.UUID `json:"task_group_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Position float64 `json:"position"`
Description sql.NullString `json:"description"`
Complete bool `json:"complete"`
DueDate sql.NullTime `json:"due_date"`
}
func (q *Queries) CreateTaskAll(ctx context.Context, arg CreateTaskAllParams) (Task, error) {
row := q.db.QueryRowContext(ctx, createTaskAll,
arg.TaskGroupID,
arg.CreatedAt,
arg.Name,
arg.Position,
arg.Description,
arg.Complete,
arg.DueDate,
)
var i Task
err := row.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
&i.Description,
&i.DueDate,
&i.Complete,
&i.CompletedAt,
)
return i, err
}
const deleteTaskByID = `-- name: DeleteTaskByID :exec const deleteTaskByID = `-- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1 DELETE FROM task WHERE task_id = $1
` `
@ -305,3 +345,29 @@ func (q *Queries) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams)
) )
return i, err return i, err
} }
const updateTaskPosition = `-- name: UpdateTaskPosition :one
UPDATE task SET position = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
`
type UpdateTaskPositionParams struct {
TaskID uuid.UUID `json:"task_id"`
Position float64 `json:"position"`
}
func (q *Queries) UpdateTaskPosition(ctx context.Context, arg UpdateTaskPositionParams) (Task, error) {
row := q.db.QueryRowContext(ctx, updateTaskPosition, arg.TaskID, arg.Position)
var i Task
err := row.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
&i.Description,
&i.DueDate,
&i.Complete,
&i.CompletedAt,
)
return i, err
}

View File

@ -13,7 +13,7 @@ import (
const createUserAccount = `-- name: CreateUserAccount :one const createUserAccount = `-- name: CreateUserAccount :one
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash, role_code) 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 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
` `
type CreateUserAccountParams struct { type CreateUserAccountParams struct {
@ -48,6 +48,7 @@ func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountPa
&i.Initials, &i.Initials,
&i.ProfileAvatarUrl, &i.ProfileAvatarUrl,
&i.RoleCode, &i.RoleCode,
&i.Bio,
) )
return i, err return i, err
} }
@ -62,7 +63,7 @@ func (q *Queries) DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) e
} }
const getAllUserAccounts = `-- name: GetAllUserAccounts :many 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 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 FROM user_account WHERE username != 'system'
` `
func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) { func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) {
@ -85,6 +86,7 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
&i.Initials, &i.Initials,
&i.ProfileAvatarUrl, &i.ProfileAvatarUrl,
&i.RoleCode, &i.RoleCode,
&i.Bio,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -119,7 +121,7 @@ func (q *Queries) GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRo
} }
const getUserAccountByID = `-- name: GetUserAccountByID :one 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 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 FROM user_account WHERE user_id = $1
` `
func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) { func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) {
@ -136,12 +138,13 @@ func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (Use
&i.Initials, &i.Initials,
&i.ProfileAvatarUrl, &i.ProfileAvatarUrl,
&i.RoleCode, &i.RoleCode,
&i.Bio,
) )
return i, err return i, err
} }
const getUserAccountByUsername = `-- name: GetUserAccountByUsername :one 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 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 FROM user_account WHERE username = $1
` `
func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) { func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) {
@ -158,12 +161,13 @@ func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string)
&i.Initials, &i.Initials,
&i.ProfileAvatarUrl, &i.ProfileAvatarUrl,
&i.RoleCode, &i.RoleCode,
&i.Bio,
) )
return i, err return i, err
} }
const setUserPassword = `-- name: SetUserPassword :one 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 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
` `
type SetUserPasswordParams struct { type SetUserPasswordParams struct {
@ -185,13 +189,52 @@ func (q *Queries) SetUserPassword(ctx context.Context, arg SetUserPasswordParams
&i.Initials, &i.Initials,
&i.ProfileAvatarUrl, &i.ProfileAvatarUrl,
&i.RoleCode, &i.RoleCode,
&i.Bio,
)
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
`
type UpdateUserAccountInfoParams struct {
UserID uuid.UUID `json:"user_id"`
Bio string `json:"bio"`
FullName string `json:"full_name"`
Initials string `json:"initials"`
Email string `json:"email"`
}
func (q *Queries) UpdateUserAccountInfo(ctx context.Context, arg UpdateUserAccountInfoParams) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, updateUserAccountInfo,
arg.UserID,
arg.Bio,
arg.FullName,
arg.Initials,
arg.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,
) )
return i, err return i, err
} }
const updateUserAccountProfileAvatarURL = `-- name: UpdateUserAccountProfileAvatarURL :one const updateUserAccountProfileAvatarURL = `-- name: UpdateUserAccountProfileAvatarURL :one
UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1 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 RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
` `
type UpdateUserAccountProfileAvatarURLParams struct { type UpdateUserAccountProfileAvatarURLParams struct {
@ -213,12 +256,13 @@ func (q *Queries) UpdateUserAccountProfileAvatarURL(ctx context.Context, arg Upd
&i.Initials, &i.Initials,
&i.ProfileAvatarUrl, &i.ProfileAvatarUrl,
&i.RoleCode, &i.RoleCode,
&i.Bio,
) )
return i, err return i, err
} }
const updateUserRole = `-- name: UpdateUserRole :one 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 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
` `
type UpdateUserRoleParams struct { type UpdateUserRoleParams struct {
@ -240,6 +284,7 @@ func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams)
&i.Initials, &i.Initials,
&i.ProfileAvatarUrl, &i.ProfileAvatarUrl,
&i.RoleCode, &i.RoleCode,
&i.Bio,
) )
return i, err return i, err
} }

File diff suppressed because it is too large Load Diff

View File

@ -111,6 +111,15 @@ type DeleteTaskGroupPayload struct {
TaskGroup *db.TaskGroup `json:"taskGroup"` TaskGroup *db.TaskGroup `json:"taskGroup"`
} }
type DeleteTaskGroupTasks struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
}
type DeleteTaskGroupTasksPayload struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
Tasks []uuid.UUID `json:"tasks"`
}
type DeleteTaskInput struct { type DeleteTaskInput struct {
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
} }
@ -151,6 +160,17 @@ type DeleteUserAccountPayload struct {
UserAccount *db.UserAccount `json:"userAccount"` UserAccount *db.UserAccount `json:"userAccount"`
} }
type DuplicateTaskGroup struct {
ProjectID uuid.UUID `json:"projectID"`
TaskGroupID uuid.UUID `json:"taskGroupID"`
Name string `json:"name"`
Position float64 `json:"position"`
}
type DuplicateTaskGroupPayload struct {
TaskGroup *db.TaskGroup `json:"taskGroup"`
}
type FindProject struct { type FindProject struct {
ProjectID uuid.UUID `json:"projectID"` ProjectID uuid.UUID `json:"projectID"`
} }
@ -296,10 +316,25 @@ type SetTaskComplete struct {
Complete bool `json:"complete"` Complete bool `json:"complete"`
} }
type SortTaskGroup struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
Tasks []TaskPositionUpdate `json:"tasks"`
}
type SortTaskGroupPayload struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
Tasks []db.Task `json:"tasks"`
}
type TaskBadges struct { type TaskBadges struct {
Checklist *ChecklistBadge `json:"checklist"` Checklist *ChecklistBadge `json:"checklist"`
} }
type TaskPositionUpdate struct {
TaskID uuid.UUID `json:"taskID"`
Position float64 `json:"position"`
}
type TeamRole struct { type TeamRole struct {
TeamID uuid.UUID `json:"teamID"` TeamID uuid.UUID `json:"teamID"`
RoleCode RoleCode `json:"roleCode"` RoleCode RoleCode `json:"roleCode"`
@ -420,6 +455,17 @@ type UpdateTeamMemberRolePayload struct {
Member *Member `json:"member"` Member *Member `json:"member"`
} }
type UpdateUserInfo struct {
Name string `json:"name"`
Initials string `json:"initials"`
Email string `json:"email"`
Bio string `json:"bio"`
}
type UpdateUserInfoPayload struct {
User *db.UserAccount `json:"user"`
}
type UpdateUserPassword struct { type UpdateUserPassword struct {
UserID uuid.UUID `json:"userID"` UserID uuid.UUID `json:"userID"`
Password string `json:"password"` Password string `json:"password"`

View File

@ -78,6 +78,7 @@ type UserAccount {
createdAt: Time! createdAt: Time!
fullName: String! fullName: String!
initials: String! initials: String!
bio: String!
role: Role! role: Role!
username: String! username: String!
profileIcon: ProfileIcon! profileIcon: ProfileIcon!
@ -547,6 +548,47 @@ extend type Mutation {
TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroup(input: DeleteTaskGroupInput!): deleteTaskGroup(input: DeleteTaskGroupInput!):
DeleteTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) DeleteTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
duplicateTaskGroup(input: DuplicateTaskGroup!):
DuplicateTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
sortTaskGroup(input: SortTaskGroup!):
SortTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroupTasks(input: DeleteTaskGroupTasks!):
DeleteTaskGroupTasksPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
}
input DeleteTaskGroupTasks {
taskGroupID: UUID!
}
type DeleteTaskGroupTasksPayload {
taskGroupID: UUID!
tasks: [UUID!]!
}
input TaskPositionUpdate {
taskID: UUID!
position: Float!
}
type SortTaskGroupPayload {
taskGroupID: UUID!
tasks: [Task!]!
}
input SortTaskGroup {
taskGroupID: UUID!
tasks: [TaskPositionUpdate!]!
}
input DuplicateTaskGroup {
projectID: UUID!
taskGroupID: UUID!
name: String!
position: Float!
}
type DuplicateTaskGroupPayload {
taskGroup: TaskGroup!
} }
input NewTaskGroupLocation { input NewTaskGroupLocation {
@ -681,6 +723,19 @@ extend type Mutation {
updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload! updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload!
updateUserRole(input: UpdateUserRole!): updateUserRole(input: UpdateUserRole!):
UpdateUserRolePayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) UpdateUserRolePayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
updateUserInfo(input: UpdateUserInfo!):
UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
}
type UpdateUserInfoPayload {
user: UserAccount!
}
input UpdateUserInfo {
name: String!
initials: String!
email: String!
bio: String!
} }
input UpdateUserPassword { input UpdateUserPassword {

View File

@ -438,6 +438,111 @@ func (r *mutationResolver) DeleteTaskGroup(ctx context.Context, input DeleteTask
return &DeleteTaskGroupPayload{true, int(deletedTasks + deletedTaskGroups), &taskGroup}, nil return &DeleteTaskGroupPayload{true, int(deletedTasks + deletedTaskGroups), &taskGroup}, nil
} }
func (r *mutationResolver) DuplicateTaskGroup(ctx context.Context, input DuplicateTaskGroup) (*DuplicateTaskGroupPayload, error) {
createdAt := time.Now().UTC()
taskGroup, err := r.Repository.CreateTaskGroup(ctx, db.CreateTaskGroupParams{ProjectID: input.ProjectID, Position: input.Position, Name: input.Name, CreatedAt: createdAt})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
originalTasks, err := r.Repository.GetTasksForTaskGroupID(ctx, input.TaskGroupID)
if err != nil && err != sql.ErrNoRows {
return &DuplicateTaskGroupPayload{}, err
}
for _, originalTask := range originalTasks {
task, err := r.Repository.CreateTaskAll(ctx, db.CreateTaskAllParams{
TaskGroupID: taskGroup.TaskGroupID, CreatedAt: createdAt, Name: originalTask.Name, Position: originalTask.Position,
Complete: originalTask.Complete, DueDate: originalTask.DueDate, Description: originalTask.Description})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
members, err := r.Repository.GetAssignedMembersForTask(ctx, originalTask.TaskID)
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
for _, member := range members {
_, err := r.Repository.CreateTaskAssigned(ctx, db.CreateTaskAssignedParams{
TaskID: task.TaskID, UserID: member.UserID, AssignedDate: member.AssignedDate})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
}
labels, err := r.Repository.GetTaskLabelsForTaskID(ctx, originalTask.TaskID)
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
for _, label := range labels {
_, err := r.Repository.CreateTaskLabelForTask(ctx, db.CreateTaskLabelForTaskParams{
TaskID: task.TaskID, ProjectLabelID: label.ProjectLabelID, AssignedDate: label.AssignedDate})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
}
checklists, err := r.Repository.GetTaskChecklistsForTask(ctx, originalTask.TaskID)
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
for _, checklist := range checklists {
newChecklist, err := r.Repository.CreateTaskChecklist(ctx, db.CreateTaskChecklistParams{
TaskID: task.TaskID, Name: checklist.Name, CreatedAt: createdAt, Position: checklist.Position})
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
checklistItems, err := r.Repository.GetTaskChecklistItemsForTaskChecklist(ctx, checklist.TaskChecklistID)
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
for _, checklistItem := range checklistItems {
item, err := r.Repository.CreateTaskChecklistItem(ctx, db.CreateTaskChecklistItemParams{
TaskChecklistID: newChecklist.TaskChecklistID,
CreatedAt: createdAt,
Name: checklistItem.Name,
Position: checklist.Position,
})
if checklistItem.Complete {
r.Repository.SetTaskChecklistItemComplete(ctx, db.SetTaskChecklistItemCompleteParams{TaskChecklistItemID: item.TaskChecklistItemID, Complete: true})
}
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
}
}
}
if err != nil {
return &DuplicateTaskGroupPayload{}, err
}
return &DuplicateTaskGroupPayload{TaskGroup: &taskGroup}, err
}
func (r *mutationResolver) SortTaskGroup(ctx context.Context, input SortTaskGroup) (*SortTaskGroupPayload, error) {
tasks := []db.Task{}
for _, task := range input.Tasks {
t, err := r.Repository.UpdateTaskPosition(ctx, db.UpdateTaskPositionParams{TaskID: task.TaskID, Position: task.Position})
if err != nil {
return &SortTaskGroupPayload{}, err
}
tasks = append(tasks, t)
}
return &SortTaskGroupPayload{Tasks: tasks, TaskGroupID: input.TaskGroupID}, nil
}
func (r *mutationResolver) DeleteTaskGroupTasks(ctx context.Context, input DeleteTaskGroupTasks) (*DeleteTaskGroupTasksPayload, error) {
tasks, err := r.Repository.GetTasksForTaskGroupID(ctx, input.TaskGroupID)
if err != nil && err != sql.ErrNoRows {
return &DeleteTaskGroupTasksPayload{}, err
}
removedTasks := []uuid.UUID{}
for _, task := range tasks {
err = r.Repository.DeleteTaskByID(ctx, task.TaskID)
if err != nil {
return &DeleteTaskGroupTasksPayload{}, err
}
removedTasks = append(removedTasks, task.TaskID)
}
return &DeleteTaskGroupTasksPayload{TaskGroupID: input.TaskGroupID, Tasks: removedTasks}, nil
}
func (r *mutationResolver) AddTaskLabel(ctx context.Context, input *AddTaskLabelInput) (*db.Task, error) { func (r *mutationResolver) AddTaskLabel(ctx context.Context, input *AddTaskLabelInput) (*db.Task, error) {
assignedDate := time.Now().UTC() assignedDate := time.Now().UTC()
_, err := r.Repository.CreateTaskLabelForTask(ctx, db.CreateTaskLabelForTaskParams{input.TaskID, input.ProjectLabelID, assignedDate}) _, err := r.Repository.CreateTaskLabelForTask(ctx, db.CreateTaskLabelForTaskParams{input.TaskID, input.ProjectLabelID, assignedDate})
@ -721,6 +826,17 @@ func (r *mutationResolver) UpdateUserRole(ctx context.Context, input UpdateUserR
return &UpdateUserRolePayload{User: &user}, nil return &UpdateUserRolePayload{User: &user}, nil
} }
func (r *mutationResolver) UpdateUserInfo(ctx context.Context, input UpdateUserInfo) (*UpdateUserInfoPayload, error) {
userID, ok := GetUserID(ctx)
if !ok {
return &UpdateUserInfoPayload{}, errors.New("invalid user ID")
}
user, err := r.Repository.UpdateUserAccountInfo(ctx, db.UpdateUserAccountInfoParams{
Bio: input.Bio, FullName: input.Name, Initials: input.Initials, Email: input.Email, UserID: userID,
})
return &UpdateUserInfoPayload{User: &user}, err
}
func (r *notificationResolver) ID(ctx context.Context, obj *db.Notification) (uuid.UUID, error) { func (r *notificationResolver) ID(ctx context.Context, obj *db.Notification) (uuid.UUID, error) {
return obj.NotificationID, nil return obj.NotificationID, nil
} }

View File

@ -78,6 +78,7 @@ type UserAccount {
createdAt: Time! createdAt: Time!
fullName: String! fullName: String!
initials: String! initials: String!
bio: String!
role: Role! role: Role!
username: String! username: String!
profileIcon: ProfileIcon! profileIcon: ProfileIcon!

View File

@ -7,6 +7,47 @@ extend type Mutation {
TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) TaskGroup! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroup(input: DeleteTaskGroupInput!): deleteTaskGroup(input: DeleteTaskGroupInput!):
DeleteTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT) DeleteTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
duplicateTaskGroup(input: DuplicateTaskGroup!):
DuplicateTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
sortTaskGroup(input: SortTaskGroup!):
SortTaskGroupPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteTaskGroupTasks(input: DeleteTaskGroupTasks!):
DeleteTaskGroupTasksPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
}
input DeleteTaskGroupTasks {
taskGroupID: UUID!
}
type DeleteTaskGroupTasksPayload {
taskGroupID: UUID!
tasks: [UUID!]!
}
input TaskPositionUpdate {
taskID: UUID!
position: Float!
}
type SortTaskGroupPayload {
taskGroupID: UUID!
tasks: [Task!]!
}
input SortTaskGroup {
taskGroupID: UUID!
tasks: [TaskPositionUpdate!]!
}
input DuplicateTaskGroup {
projectID: UUID!
taskGroupID: UUID!
name: String!
position: Float!
}
type DuplicateTaskGroupPayload {
taskGroup: TaskGroup!
} }
input NewTaskGroupLocation { input NewTaskGroupLocation {

View File

@ -11,6 +11,19 @@ extend type Mutation {
updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload! updateUserPassword(input: UpdateUserPassword!): UpdateUserPasswordPayload!
updateUserRole(input: UpdateUserRole!): updateUserRole(input: UpdateUserRole!):
UpdateUserRolePayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) UpdateUserRolePayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
updateUserInfo(input: UpdateUserInfo!):
UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
}
type UpdateUserInfoPayload {
user: UserAccount!
}
input UpdateUserInfo {
name: String!
initials: String!
email: String!
bio: String!
} }
input UpdateUserPassword { input UpdateUserPassword {

View File

@ -14,8 +14,6 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
var jwtKey = []byte("taskcafe_test_key")
type authResource struct{} type authResource struct{}
// LoginRequestData is the request data when a user logs in // LoginRequestData is the request data when a user logs in
@ -69,7 +67,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode) accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode, h.jwtKey)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
@ -123,7 +121,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode) accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
@ -190,7 +188,7 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode) accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
@ -251,10 +249,12 @@ func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request)
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1) refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt}) refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode) log.WithField("userID", user.UserID.String()).Info("creating install access token")
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
log.Info(accessTokenString)
w.Header().Set("Content-type", "application/json") w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{

View File

@ -5,7 +5,9 @@ import (
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"os" "os"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -14,6 +16,7 @@ import (
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/frontend" "github.com/jordanknott/taskcafe/internal/frontend"
"github.com/jordanknott/taskcafe/internal/utils"
) )
// Frontend serves the index.html file // Frontend serves the index.html file
@ -30,7 +33,7 @@ func (h *TaskcafeHandler) Frontend(w http.ResponseWriter, r *http.Request) {
// ProfileImageUpload handles a user uploading a new avatar profile image // ProfileImageUpload handles a user uploading a new avatar profile image
func (h *TaskcafeHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Request) { func (h *TaskcafeHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Request) {
log.Info("preparing to upload file") log.Info("preparing to upload file")
userID, ok := r.Context().Value("userID").(uuid.UUID) userID, ok := r.Context().Value(utils.UserIDKey).(uuid.UUID)
if !ok { if !ok {
log.Error("not a valid uuid") log.Error("not a valid uuid")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -47,22 +50,24 @@ func (h *TaskcafeHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Requ
return return
} }
defer file.Close() defer file.Close()
log.WithFields(log.Fields{"filename": handler.Filename, "size": handler.Size, "header": handler.Header}).Info("file metadata") filename := strings.ReplaceAll(handler.Filename, " ", "-")
encodedFilename := url.QueryEscape(filename)
log.WithFields(log.Fields{"filename": encodedFilename, "size": handler.Size, "header": handler.Header}).Info("file metadata")
fileBytes, err := ioutil.ReadAll(file) fileBytes, err := ioutil.ReadAll(file)
if err != nil { if err != nil {
log.WithError(err).Error("while reading file") log.WithError(err).Error("while reading file")
return return
} }
err = ioutil.WriteFile("uploads/"+handler.Filename, fileBytes, 0644) err = ioutil.WriteFile("uploads/"+filename, fileBytes, 0644)
if err != nil { if err != nil {
log.WithError(err).Error("while reading file") log.WithError(err).Error("while reading file")
return return
} }
h.repo.UpdateUserAccountProfileAvatarURL(r.Context(), db.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{String: "http://localhost:3333/uploads/" + handler.Filename, Valid: true}}) h.repo.UpdateUserAccountProfileAvatarURL(r.Context(), db.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{String: "/uploads/" + encodedFilename, Valid: true}})
// return that we have successfully uploaded our file! // return that we have successfully uploaded our file!
log.Info("file uploaded") log.Info("file uploaded")
json.NewEncoder(w).Encode(AvatarUploadResponseData{URL: "http://localhost:3333/uploads/" + handler.Filename, UserID: userID.String()}) json.NewEncoder(w).Encode(AvatarUploadResponseData{URL: "/uploads/" + encodedFilename, UserID: userID.String()})
} }

View File

@ -12,7 +12,12 @@ import (
) )
// AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header // AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header
func AuthenticationMiddleware(next http.Handler) http.Handler { type AuthenticationMiddleware struct {
jwtKey []byte
}
// Middleware returns the middleware handler
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearerTokenRaw := r.Header.Get("Authorization") bearerTokenRaw := r.Header.Get("Authorization")
splitToken := strings.Split(bearerTokenRaw, "Bearer") splitToken := strings.Split(bearerTokenRaw, "Bearer")
@ -21,7 +26,7 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
return return
} }
accessTokenString := strings.TrimSpace(splitToken[1]) accessTokenString := strings.TrimSpace(splitToken[1])
accessClaims, err := auth.ValidateAccessToken(accessTokenString) accessClaims, err := auth.ValidateAccessToken(accessTokenString, m.jwtKey)
if err != nil { if err != nil {
if _, ok := err.(*auth.ErrExpiredToken); ok { if _, ok := err.(*auth.ErrExpiredToken); ok {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)

View File

@ -59,11 +59,12 @@ func (h FrontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// TaskcafeHandler contains all the route handlers // TaskcafeHandler contains all the route handlers
type TaskcafeHandler struct { type TaskcafeHandler struct {
repo db.Repository repo db.Repository
jwtKey []byte
} }
// NewRouter creates a new router for chi // NewRouter creates a new router for chi
func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) { func NewRouter(dbConnection *sqlx.DB, jwtKey []byte) (chi.Router, error) {
formatter := new(log.TextFormatter) formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05" formatter.TimestampFormat = "02-01-2006 15:04:05"
formatter.FullTimestamp = true formatter.FullTimestamp = true
@ -79,7 +80,7 @@ func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
r.Use(middleware.Timeout(60 * time.Second)) r.Use(middleware.Timeout(60 * time.Second))
repository := db.NewRepository(dbConnection) repository := db.NewRepository(dbConnection)
taskcafeHandler := TaskcafeHandler{*repository} taskcafeHandler := TaskcafeHandler{*repository, jwtKey}
var imgServer = http.FileServer(http.Dir("./uploads/")) var imgServer = http.FileServer(http.Dir("./uploads/"))
r.Group(func(mux chi.Router) { r.Group(func(mux chi.Router) {
@ -88,8 +89,9 @@ func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer)) mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer))
}) })
auth := AuthenticationMiddleware{jwtKey}
r.Group(func(mux chi.Router) { r.Group(func(mux chi.Router) {
mux.Use(AuthenticationMiddleware) mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload) mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
mux.Post("/auth/install", taskcafeHandler.InstallHandler) mux.Post("/auth/install", taskcafeHandler.InstallHandler)
mux.Handle("/graphql", graph.NewHandler(*repository)) mux.Handle("/graphql", graph.NewHandler(*repository))

View File

@ -0,0 +1 @@
ALTER TABLE user_account ADD COLUMN bio text NOT NULL DEFAULT '';

2
scripts/lint.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
yarn --cwd frontend eslint $(echo $1 | sed 's/frontend\///g')