feature: add first time install process

This commit is contained in:
Jordan Knott 2020-07-16 19:40:23 -05:00
parent 90515f6aa4
commit 2cf6be082c
42 changed files with 1834 additions and 1053 deletions

View File

@ -24,7 +24,7 @@
"plugin:@typescript-eslint/recommended"
],
"rules": {
"prettier/prettier": "warning",
"prettier/prettier": "error",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",

View File

@ -61,7 +61,7 @@
"react-beautiful-dnd": "^13.0.0",
"react-datepicker": "^2.14.1",
"react-dom": "^16.12.0",
"react-hook-form": "^5.2.0",
"react-hook-form": "^6.0.6",
"react-markdown": "^4.3.1",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Router, Switch, Route } from 'react-router-dom';
import {Router, Switch, Route} from 'react-router-dom';
import * as H from 'history';
import Dashboard from 'Dashboard';
@ -8,6 +8,7 @@ import Projects from 'Projects';
import Project from 'Projects/Project';
import Teams from 'Teams';
import Login from 'Auth';
import Install from 'Install';
import Profile from 'Profile';
import styled from 'styled-components';
@ -23,9 +24,10 @@ type RoutesProps = {
history: H.History;
};
const Routes = ({ history }: RoutesProps) => (
const Routes = ({history}: RoutesProps) => (
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/install" component={Install} />
<MainContent>
<Route exact path="/" component={Dashboard} />
<Route exact path="/projects" component={Projects} />

View File

@ -196,7 +196,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({history, name, projec
<Popup title={null} tab={0}>
<ProjectSettings
onDeleteProject={() => {
setTab(1);
setTab(1, 300);
}}
/>
</Popup>

View File

@ -1,18 +1,22 @@
import React, {useState, useEffect} from 'react';
import React, { useState, useEffect } from 'react';
import jwtDecode from 'jwt-decode';
import {createBrowserHistory} from 'history';
import {setAccessToken} from 'shared/utils/accessToken';
import styled, {ThemeProvider} from 'styled-components';
import { createBrowserHistory } from 'history';
import { Router } from 'react-router';
import { PopupProvider } from 'shared/components/PopupMenu';
import { setAccessToken } from 'shared/utils/accessToken';
import styled, { ThemeProvider } from 'styled-components';
import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles';
import {theme} from './ThemeStyles';
import { theme } from './ThemeStyles';
import Routes from './Routes';
import {UserIDContext} from './context';
import { UserIDContext } from './context';
import Navbar from './Navbar';
import {Router} from 'react-router';
import {PopupProvider} from 'shared/components/PopupMenu';
const history = createBrowserHistory();
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
};
const App = () => {
const [loading, setLoading] = useState(true);
@ -23,15 +27,18 @@ const App = () => {
method: 'POST',
credentials: 'include',
}).then(async x => {
const {status} = x;
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const {accessToken} = response;
const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
setUserID(claims.userId);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}
}
setLoading(false);
});
@ -39,7 +46,7 @@ const App = () => {
return (
<>
<UserIDContext.Provider value={{userID, setUserID}}>
<UserIDContext.Provider value={{ userID, setUserID }}>
<ThemeProvider theme={theme}>
<NormalizeStyles />
<BaseStyles />
@ -48,10 +55,10 @@ const App = () => {
{loading ? (
<div>loading</div>
) : (
<>
<Routes history={history} />
</>
)}
<>
<Routes history={history} />
</>
)}
</PopupProvider>
</Router>
</ThemeProvider>

View File

@ -1,22 +1,22 @@
import React, {useState, useEffect, useContext} from 'react';
import {useForm} from 'react-hook-form';
import {useHistory} from 'react-router';
import React, { useState, useEffect, useContext } from 'react';
import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import {setAccessToken} from 'shared/utils/accessToken';
import { setAccessToken } from 'shared/utils/accessToken';
import Login from 'shared/components/Login';
import {Container, LoginWrapper} from './Styles';
import { Container, LoginWrapper } from './Styles';
import UserIDContext from 'App/context';
import JwtDecode from 'jwt-decode';
const Auth = () => {
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
const history = useHistory();
const {setUserID} = useContext(UserIDContext);
const { setUserID } = useContext(UserIDContext);
const login = (
data: LoginFormData,
setComplete: (val: boolean) => void,
setError: (field: string, eType: string, message: string) => void,
setError: (name: 'username' | 'password', error: ErrorOption) => void,
) => {
fetch('/auth/login', {
credentials: 'include',
@ -28,12 +28,12 @@ const Auth = () => {
}).then(async x => {
if (x.status === 401) {
setInvalidLoginAttempt(invalidLoginAttempt + 1);
setError('username', 'invalid', 'Invalid username');
setError('password', 'invalid', 'Invalid password');
setError('username', { type: 'error', message: 'Invalid username' });
setError('password', { type: 'error', message: 'Invalid password' });
setComplete(true);
} else {
const response = await x.json();
const {accessToken} = response;
const { accessToken } = response;
const claims: JWTToken = JwtDecode(accessToken);
setUserID(claims.userId);
setComplete(true);
@ -49,7 +49,7 @@ const Auth = () => {
method: 'POST',
credentials: 'include',
}).then(async x => {
const {status} = x;
const { status } = x;
if (status === 200) {
history.replace('/projects');
}

View File

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

View File

@ -0,0 +1,85 @@
import React, { useEffect, useContext } from 'react';
import axios from 'axios';
import Register from 'shared/components/Register';
import { Container, LoginWrapper } from './Styles';
import { useCreateUserAccountMutation, useMeQuery, MeDocument, MeQuery } from 'shared/generated/graphql';
import { useHistory } from 'react-router';
import { getAccessToken, setAccessToken } from 'shared/utils/accessToken';
import updateApolloCache from 'shared/utils/cache';
import produce from 'immer';
import { useApolloClient } from '@apollo/react-hooks';
import UserIDContext from 'App/context';
import jwtDecode from 'jwt-decode';
const Install = () => {
const client = useApolloClient();
const history = useHistory();
const { setUserID } = useContext(UserIDContext);
useEffect(() => {
fetch('/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
const response: RefreshTokenResponse = await x.json();
const { isInstalled } = response;
if (status === 200 && isInstalled) {
history.replace('/projects');
}
});
}, []);
return (
<Container>
<LoginWrapper>
<Register
onSubmit={(data, setComplete, setError) => {
const accessToken = getAccessToken();
if (data.password !== data.password_confirm) {
setError('password', { type: 'error', message: 'Passwords must match' });
setError('password_confirm', { type: 'error', message: 'Passwords must match' });
} else {
axios
.post(
'/auth/install',
{
user: {
username: data.username,
roleCode: 'admin',
email: data.email,
password: data.password,
initials: data.initials,
fullname: data.fullname,
},
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
)
.then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.data;
const { accessToken, isInstalled } = response;
const claims: JWTToken = jwtDecode(accessToken);
setUserID(claims.userId);
setAccessToken(accessToken);
if (!isInstalled) {
history.replace('/install');
}
}
history.push('/projects');
});
}
setComplete(true);
}}
/>
</LoginWrapper>
</Container>
);
};
export default Install;

View File

@ -1,12 +1,12 @@
import React, {useState, useRef, useContext, useEffect} from 'react';
import {MENU_TYPES} from 'shared/components/TopNavbar';
import React, { useState, useRef, useContext, useEffect } from 'react';
import { MENU_TYPES } from 'shared/components/TopNavbar';
import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, {ProjectPopup} from 'App/TopNavbar';
import styled, {css} from 'styled-components/macro';
import {Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter} from 'shared/icons';
import LabelManagerEditor from '../LabelManagerEditor'
import {usePopup, Popup} from 'shared/components/PopupMenu';
import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation} from 'react-router-dom';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import styled, { css } from 'styled-components/macro';
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
import LabelManagerEditor from '../LabelManagerEditor';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
import {
useSetProjectOwnerMutation,
useUpdateProjectMemberRoleMutation,
@ -61,7 +61,7 @@ const ProjectActions = styled.div`
align-items: center;
`;
const ProjectAction = styled.div<{disabled?: boolean}>`
const ProjectAction = styled.div<{ disabled?: boolean }>`
cursor: pointer;
display: flex;
align-items: center;
@ -103,18 +103,21 @@ const initialQuickCardEditorState: QuickCardEditorState = {
};
type ProjectBoardProps = {
onCardLabelClick: () => void;
cardLabelVariant: CardLabelVariant;
projectID: string;
};
const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
const [assignTask] = useAssignTaskMutation();
const [unassignTask] = useUnassignTaskMutation();
const $labelsRef = useRef<HTMLDivElement>(null);
const match = useRouteMatch();
const labelsRef = useRef<Array<ProjectLabel>>([]);
const {showPopup, hidePopup} = usePopup();
const { showPopup, hidePopup } = usePopup();
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
const {userID} = useContext(UserIDContext);
const { userID } = useContext(UserIDContext);
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
const history = useHistory();
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
@ -128,7 +131,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
);
}),
{projectId: projectID},
{ projectId: projectID },
);
},
});
@ -140,13 +143,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const {taskGroups} = cache.findProject;
const { taskGroups } = cache.findProject;
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
if (idx !== -1) {
draftCache.findProject.taskGroups[idx].tasks.push({...newTaskData.data.createTask});
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
}
}),
{projectId: projectID},
{ projectId: projectID },
);
},
});
@ -156,17 +159,18 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache => produce(cache, draftCache => {
draftCache.findProject.taskGroups.push({...newTaskGroupData.data.createTaskGroup, tasks: []});
}),
{projectId: projectID},
cache =>
produce(cache, draftCache => {
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
}),
{ projectId: projectID },
);
},
});
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
const {loading, data} = useFindProjectQuery({
variables: {projectId: projectID},
const { loading, data } = useFindProjectQuery({
variables: { projectId: projectID },
});
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
@ -178,9 +182,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
const {previousTaskGroupID, task} = newTask.data.updateTaskLocation;
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
if (previousTaskGroupID !== task.taskGroup.id) {
const {taskGroups} = cache.findProject;
const { taskGroups } = cache.findProject;
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
@ -189,12 +193,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
);
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
...taskGroups[newTaskGroupIdx].tasks,
{...task},
{ ...task },
];
}
}
}),
{projectId: projectID},
{ projectId: projectID },
);
},
});
@ -222,7 +226,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
console.log(`position ${position}`);
createTask({
variables: {taskGroupID, name, position},
variables: { taskGroupID, name, position },
optimisticResponse: {
__typename: 'Mutation',
createTask: {
@ -258,7 +262,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
if (lastColumn) {
position = lastColumn.position * 2 + 1;
}
createTaskGroup({variables: {projectID, name: listName, position}});
createTaskGroup({ variables: { projectID, name: listName, position } });
}
};
@ -341,6 +345,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
onTaskClick={task => {
history.push(`${match.url}/c/${task.id}`);
}}
onCardLabelClick={onCardLabelClick}
cardLabelVariant={cardLabelVariant}
onTaskDrop={(droppedTask, previousTaskGroupID) => {
updateTaskLocation({
variables: {
@ -370,7 +376,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
}}
onTaskGroupDrop={droppedTaskGroup => {
updateTaskGroupLocation({
variables: {taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position},
variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position },
optimisticResponse: {
__typename: 'Mutation',
updateTaskGroupLocation: {
@ -400,7 +406,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
}
}}
onChangeTaskGroupName={(taskGroupID, name) => {
updateTaskGroupName({variables: {taskGroupID, name}});
updateTaskGroupName({ variables: { taskGroupID, name } });
}}
onQuickEditorOpen={onQuickEditorOpen}
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
@ -410,7 +416,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
<ListActions
taskGroupID={taskGroupID}
onArchiveTaskGroup={tgID => {
deleteTaskGroup({variables: {taskGroupID: tgID}});
deleteTaskGroup({ variables: { taskGroupID: tgID } });
hidePopup();
}}
/>
@ -423,7 +429,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
task={currentQuickTask}
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => {
updateTaskName({variables: {taskID, name: cardName}});
updateTaskName({ variables: { taskID, name: cardName } });
}}
onOpenMembersPopup={($targetRef, task) => {
showPopup(
@ -434,9 +440,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
activeMembers={task.assigned ?? []}
onMemberChange={(member, isActive) => {
if (isActive) {
assignTask({variables: {taskID: task.id, userID: userID ?? ''}});
assignTask({ variables: { taskID: task.id, userID: userID ?? '' } });
} else {
unassignTask({variables: {taskID: task.id, userID: userID ?? ''}});
unassignTask({ variables: { taskID: task.id, userID: userID ?? '' } });
}
}}
/>
@ -464,7 +470,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
$targetRef,
<LabelManagerEditor
onLabelToggle={labelID => {
toggleTaskLabel({variables: {taskID: task.id, projectLabelID: labelID}});
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}}
labelColors={data.labelColors}
labels={labelsRef}
@ -475,7 +481,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
}}
onArchiveCard={(_listId: string, cardId: string) =>
deleteTask({
variables: {taskID: cardId},
variables: { taskID: cardId },
update: client => {
updateApolloCache<FindProjectQuery>(
client,
@ -487,7 +493,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
tasks: taskGroup.tasks.filter(t => t.id !== cardId),
}));
}),
{projectId: projectID},
{ projectId: projectID },
);
},
})
@ -499,11 +505,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({variables: {taskID: t.id, dueDate: null}});
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({variables: {taskID: t.id, dueDate: newDueDate}});
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
}}
onCancel={() => {}}
@ -512,7 +518,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
);
}}
onToggleComplete={task => {
setTaskComplete({variables: {taskID: task.id, complete: !task.complete}});
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}}
target={quickCardEditor.target}
/>

View File

@ -5,22 +5,22 @@ import PopupMenu, { Popup, usePopup } from 'shared/components/PopupMenu';
import MemberManager from 'shared/components/MemberManager';
import { useRouteMatch, useHistory } from 'react-router';
import {
useDeleteTaskChecklistMutation,
useUpdateTaskChecklistNameMutation,
useUpdateTaskChecklistItemLocationMutation,
useCreateTaskChecklistMutation,
useFindTaskQuery,
useUpdateTaskDueDateMutation,
useSetTaskCompleteMutation,
useAssignTaskMutation,
useUnassignTaskMutation,
useSetTaskChecklistItemCompleteMutation,
useUpdateTaskChecklistLocationMutation,
useDeleteTaskChecklistItemMutation,
useUpdateTaskChecklistItemNameMutation,
useCreateTaskChecklistItemMutation,
FindTaskDocument,
FindTaskQuery,
useDeleteTaskChecklistMutation,
useUpdateTaskChecklistNameMutation,
useUpdateTaskChecklistItemLocationMutation,
useCreateTaskChecklistMutation,
useFindTaskQuery,
useUpdateTaskDueDateMutation,
useSetTaskCompleteMutation,
useAssignTaskMutation,
useUnassignTaskMutation,
useSetTaskChecklistItemCompleteMutation,
useUpdateTaskChecklistLocationMutation,
useDeleteTaskChecklistItemMutation,
useUpdateTaskChecklistItemNameMutation,
useCreateTaskChecklistItemMutation,
FindTaskDocument,
FindTaskQuery,
} from 'shared/generated/graphql';
import UserIDContext from 'App/context';
import MiniProfile from 'shared/components/MiniProfile';
@ -33,23 +33,23 @@ import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache';
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => {
return (
prev +
next.items.reduce((innerPrev: any, _item: any) => {
return innerPrev + 1;
}, 0)
);
}, 0);
const complete = checklists.reduce(
(prev: any, next: any) =>
prev +
next.items.reduce((innerPrev: any, item: any) => {
return innerPrev + (item.complete ? 1 : 0);
}, 0),
0,
const total = checklists.reduce((prev: any, next: any) => {
return (
prev +
next.items.reduce((innerPrev: any, _item: any) => {
return innerPrev + 1;
}, 0)
);
return { total, complete };
}, 0);
const complete = checklists.reduce(
(prev: any, next: any) =>
prev +
next.items.reduce((innerPrev: any, item: any) => {
return innerPrev + (item.complete ? 1 : 0);
}, 0),
0,
);
return { total, complete };
};
const DeleteChecklistButton = styled(Button)`
@ -58,7 +58,7 @@ const DeleteChecklistButton = styled(Button)`
margin-top: 8px;
`;
type CreateChecklistData = {
name: string;
name: string;
};
const CreateChecklistForm = styled.form`
display: flex;
@ -80,437 +80,441 @@ const InputError = styled.span`
font-size: 12px;
`;
type CreateChecklistPopupProps = {
onCreateChecklist: (data: CreateChecklistData) => void;
onCreateChecklist: (data: CreateChecklistData) => void;
};
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
const { register, handleSubmit, errors } = useForm<CreateChecklistData>();
const createUser = (data: CreateChecklistData) => {
onCreateChecklist(data);
};
console.log(errors);
return (
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
<CreateChecklistInput
floatingLabel
value="Checklist"
width="100%"
label="Name"
id="name"
name="name"
variant="alternate"
ref={register({ required: 'Checklist name is required' })}
/>
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
</CreateChecklistForm>
);
const { register, handleSubmit, errors } = useForm<CreateChecklistData>();
const createUser = (data: CreateChecklistData) => {
onCreateChecklist(data);
};
console.log(errors);
return (
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
<CreateChecklistInput
floatingLabel
defaultValue="Checklist"
width="100%"
label="Name"
id="name"
name="name"
variant="alternate"
ref={register({ required: 'Checklist name is required' })}
/>
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
</CreateChecklistForm>
);
};
type DetailsProps = {
taskID: string;
projectURL: string;
onTaskNameChange: (task: Task, newName: string) => void;
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
onDeleteTask: (task: Task) => void;
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
availableMembers: Array<TaskUser>;
refreshCache: () => void;
taskID: string;
projectURL: string;
onTaskNameChange: (task: Task, newName: string) => void;
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
onDeleteTask: (task: Task) => void;
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
availableMembers: Array<TaskUser>;
refreshCache: () => void;
};
const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
const Details: React.FC<DetailsProps> = ({
projectURL,
taskID,
onTaskNameChange,
onTaskDescriptionChange,
onDeleteTask,
onOpenAddLabelPopup,
availableMembers,
refreshCache,
projectURL,
taskID,
onTaskNameChange,
onTaskDescriptionChange,
onDeleteTask,
onOpenAddLabelPopup,
availableMembers,
refreshCache,
}) => {
const { userID } = useContext(UserIDContext);
const { showPopup, hidePopup } = usePopup();
const history = useHistory();
const match = useRouteMatch();
const [currentMemberTask, setCurrentMemberTask] = useState('');
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
console.log(`${checklistID} !== ${prevChecklistID}`);
if (checklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
console.log(`oldIdx ${oldIdx} newIdx ${newIdx}`);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id);
console.log(item);
if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
i => i.id !== checklistItem.id,
);
draftCache.findTask.checklists[newIdx].items.push({
...item,
position: checklistItem.position,
taskChecklistID: checklistID,
});
}
}
}
}),
{ taskID },
const { userID } = useContext(UserIDContext);
const { showPopup, hidePopup } = usePopup();
const history = useHistory();
const match = useRouteMatch();
const [currentMemberTask, setCurrentMemberTask] = useState('');
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
console.log(`${checklistID} !== ${prevChecklistID}`);
if (checklistID !== prevChecklistID) {
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
console.log(`oldIdx ${oldIdx} newIdx ${newIdx}`);
if (oldIdx > -1 && newIdx > -1) {
const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id);
console.log(item);
if (item) {
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
i => i.id !== checklistItem.id,
);
draftCache.findTask.checklists[newIdx].items.push({
...item,
position: checklistItem.position,
taskChecklistID: checklistID,
});
}
}
}
}),
{ taskID },
);
},
});
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
update: client => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}),
{ taskID },
);
},
});
const [deleteTaskChecklist] = useDeleteTaskChecklistMutation({
update: (client, deleteData) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { checklists } = cache.findTask;
console.log(deleteData);
draftCache.findTask.checklists = checklists.filter(
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
);
},
});
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
update: client => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}),
{ taskID },
);
},
});
const [deleteTaskChecklist] = useDeleteTaskChecklistMutation({
update: (client, deleteData) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const { checklists } = cache.findTask;
console.log(deleteData)
draftCache.findTask.checklists = checklists.filter(c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id);
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
if (complete === 0 && total === 0) {
draftCache.findTask.badges.checklist = null;
}
}),
{ taskID },
);
},
});
const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation();
const [createTaskChecklist] = useCreateTaskChecklistMutation({
update: (client, createData) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
}),
{ taskID },
);
},
});
const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation();
const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({
update: (client, deleteData) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID)
if (targetIdx > -1) {
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(c => item.id !== c.id);
}
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}),
{ taskID },
);
},
});
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
update: (client, newTaskItem) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}
}),
{ taskID },
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => {
refetch();
refreshCache();
},
});
const [assignTask] = useAssignTaskMutation({
onCompleted: () => {
refetch();
refreshCache();
},
});
const [unassignTask] = useUnassignTaskMutation({
onCompleted: () => {
refetch();
refreshCache();
},
});
if (loading) {
return <div>loading</div>;
}
if (!data) {
return <div>loading</div>;
}
return (
<>
<Modal
width={768}
onClose={() => {
history.push(projectURL);
}}
renderContent={() => {
return (
<TaskDetails
task={data.findTask}
onChecklistDrop={checklist => {
updateTaskChecklistLocation({
variables: { checklistID: checklist.id, position: checklist.position },
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
if (complete === 0 && total === 0) {
draftCache.findTask.badges.checklist = null;
}
}),
{ taskID },
);
},
});
const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation();
const [createTaskChecklist] = useCreateTaskChecklistMutation({
update: (client, createData) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = createData.data.createTaskChecklist;
draftCache.findTask.checklists.push({ ...item });
}),
{ taskID },
);
},
});
const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation();
const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({
update: (client, deleteData) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
if (targetIdx > -1) {
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
c => item.id !== c.id,
);
}
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}),
{ taskID },
);
},
});
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
update: (client, newTaskItem) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
const item = newTaskItem.data.createTaskChecklistItem;
const { checklists } = cache.findTask;
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
if (idx !== -1) {
draftCache.findTask.checklists[idx].items.push({ ...item });
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
draftCache.findTask.badges.checklist = {
__typename: 'ChecklistBadge',
complete,
total,
};
}
}),
{ taskID },
);
},
});
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => {
refetch();
refreshCache();
},
});
const [assignTask] = useAssignTaskMutation({
onCompleted: () => {
refetch();
refreshCache();
},
});
const [unassignTask] = useUnassignTaskMutation({
onCompleted: () => {
refetch();
refreshCache();
},
});
if (loading) {
return <div>loading</div>;
}
if (!data) {
return <div>loading</div>;
}
return (
<>
<Modal
width={768}
onClose={() => {
history.push(projectURL);
}}
renderContent={() => {
return (
<TaskDetails
task={data.findTask}
onChecklistDrop={checklist => {
updateTaskChecklistLocation({
variables: { checklistID: checklist.id, position: checklist.position },
optimisticResponse: {
__typename: 'Mutation',
updateTaskChecklistLocation: {
__typename: 'UpdateTaskChecklistLocationPayload',
checklist: {
__typename: 'TaskChecklist',
position: checklist.position,
id: checklist.id,
},
},
},
});
}}
onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => {
updateTaskChecklistItemLocation({
variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position },
optimisticResponse: {
__typename: 'Mutation',
updateTaskChecklistLocation: {
__typename: 'UpdateTaskChecklistLocationPayload',
checklist: {
__typename: 'TaskChecklist',
position: checklist.position,
id: checklist.id,
},
},
},
});
}}
onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => {
updateTaskChecklistItemLocation({
variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position },
optimisticResponse: {
__typename: 'Mutation',
updateTaskChecklistItemLocation: {
__typename: 'UpdateTaskChecklistItemLocationPayload',
prevChecklistID,
checklistID,
checklistItem: {
__typename: 'TaskChecklistItem',
position: checklistItem.position,
id: checklistItem.id,
taskChecklistID: checklistID,
},
},
},
});
}}
onTaskNameChange={onTaskNameChange}
onTaskDescriptionChange={onTaskDescriptionChange}
onToggleTaskComplete={task => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}}
onDeleteTask={onDeleteTask}
onChangeItemName={(itemID, itemName) => {
updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } });
}}
onCloseModal={() => history.push(projectURL)}
onChangeChecklistName={(checklistID, newName) => {
updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } });
}}
onDeleteItem={(checklistID, itemID) => {
deleteTaskChecklistItem({
variables: { taskChecklistItemID: itemID },
optimisticResponse: {
__typename: 'Mutation',
deleteTaskChecklistItem: {
__typename: 'DeleteTaskChecklistItemPayload',
ok: true,
taskChecklistItem: {
__typename: 'TaskChecklistItem',
id: itemID,
taskChecklistID: checklistID,
}
}
}
});
}}
onToggleChecklistItem={(itemID, complete) => {
setTaskChecklistItemComplete({
variables: { taskChecklistItemID: itemID, complete },
optimisticResponse: {
__typename: 'Mutation',
setTaskChecklistItemComplete: {
__typename: 'TaskChecklistItem',
id: itemID,
complete,
},
},
});
}}
onAddItem={(taskChecklistID, name, position) => {
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
}}
onMemberProfile={($targetRef, memberID) => {
const member = data.findTask.assigned.find(m => m.id === memberID);
if (member) {
showPopup(
$targetRef,
<Popup title={null} onClose={() => { }} tab={0}>
<MiniProfile
user={member}
bio="None"
onRemoveFromTask={() => {
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
}}
/>
</Popup>,
);
}
}}
onOpenAddMemberPopup={(task, $targetRef) => {
showPopup(
$targetRef,
<Popup title="Members" tab={0} onClose={() => { }}>
<MemberManager
availableMembers={availableMembers}
activeMembers={data.findTask.assigned}
onMemberChange={(member, isActive) => {
if (isActive) {
assignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
} else {
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
}
}}
/>
</Popup>,
);
}}
onOpenAddLabelPopup={onOpenAddLabelPopup}
onOpenAddChecklistPopup={(_task, $target) => {
showPopup(
$target,
<Popup
title={'Add checklist'}
tab={0}
onClose={() => {
hidePopup();
}}
>
<CreateChecklistPopup
onCreateChecklist={checklistData => {
let position = 65535;
console.log(data.findTask.checklists);
if (data.findTask.checklists) {
const [lastChecklist] = data.findTask.checklists.slice(-1);
console.log(`lastCheclist ${lastChecklist}`);
if (lastChecklist) {
position = lastChecklist.position * 2 + 1;
}
}
createTaskChecklist({
variables: {
taskID: data.findTask.id,
name: checklistData.name,
position,
},
});
hidePopup();
}}
/>
</Popup>,
);
}}
onDeleteChecklist={($target, checklistID) => {
showPopup(
$target,
<Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}>
<p>Deleting a checklist is permanent and there is no way to get it back.</p>
<DeleteChecklistButton
color="danger"
onClick={() => {
deleteTaskChecklist({ variables: { taskChecklistID: checklistID } });
hidePopup();
}}
>
Delete Checklist
optimisticResponse: {
__typename: 'Mutation',
updateTaskChecklistItemLocation: {
__typename: 'UpdateTaskChecklistItemLocationPayload',
prevChecklistID,
checklistID,
checklistItem: {
__typename: 'TaskChecklistItem',
position: checklistItem.position,
id: checklistItem.id,
taskChecklistID: checklistID,
},
},
},
});
}}
onTaskNameChange={onTaskNameChange}
onTaskDescriptionChange={onTaskDescriptionChange}
onToggleTaskComplete={task => {
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
}}
onDeleteTask={onDeleteTask}
onChangeItemName={(itemID, itemName) => {
updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } });
}}
onCloseModal={() => history.push(projectURL)}
onChangeChecklistName={(checklistID, newName) => {
updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } });
}}
onDeleteItem={(checklistID, itemID) => {
deleteTaskChecklistItem({
variables: { taskChecklistItemID: itemID },
optimisticResponse: {
__typename: 'Mutation',
deleteTaskChecklistItem: {
__typename: 'DeleteTaskChecklistItemPayload',
ok: true,
taskChecklistItem: {
__typename: 'TaskChecklistItem',
id: itemID,
taskChecklistID: checklistID,
},
},
},
});
}}
onToggleChecklistItem={(itemID, complete) => {
setTaskChecklistItemComplete({
variables: { taskChecklistItemID: itemID, complete },
optimisticResponse: {
__typename: 'Mutation',
setTaskChecklistItemComplete: {
__typename: 'TaskChecklistItem',
id: itemID,
complete,
},
},
});
}}
onAddItem={(taskChecklistID, name, position) => {
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
}}
onMemberProfile={($targetRef, memberID) => {
const member = data.findTask.assigned.find(m => m.id === memberID);
if (member) {
showPopup(
$targetRef,
<Popup title={null} onClose={() => {}} tab={0}>
<MiniProfile
user={member}
bio="None"
onRemoveFromTask={() => {
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
}}
/>
</Popup>,
);
}
}}
onOpenAddMemberPopup={(task, $targetRef) => {
showPopup(
$targetRef,
<Popup title="Members" tab={0} onClose={() => {}}>
<MemberManager
availableMembers={availableMembers}
activeMembers={data.findTask.assigned}
onMemberChange={(member, isActive) => {
if (isActive) {
assignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
} else {
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
}
}}
/>
</Popup>,
);
}}
onOpenAddLabelPopup={onOpenAddLabelPopup}
onOpenAddChecklistPopup={(_task, $target) => {
showPopup(
$target,
<Popup
title={'Add checklist'}
tab={0}
onClose={() => {
hidePopup();
}}
>
<CreateChecklistPopup
onCreateChecklist={checklistData => {
let position = 65535;
console.log(data.findTask.checklists);
if (data.findTask.checklists) {
const [lastChecklist] = data.findTask.checklists.slice(-1);
console.log(`lastCheclist ${lastChecklist}`);
if (lastChecklist) {
position = lastChecklist.position * 2 + 1;
}
}
createTaskChecklist({
variables: {
taskID: data.findTask.id,
name: checklistData.name,
position,
},
});
hidePopup();
}}
/>
</Popup>,
);
}}
onDeleteChecklist={($target, checklistID) => {
showPopup(
$target,
<Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}>
<p>Deleting a checklist is permanent and there is no way to get it back.</p>
<DeleteChecklistButton
color="danger"
onClick={() => {
deleteTaskChecklist({ variables: { taskChecklistID: checklistID } });
hidePopup();
}}
>
Delete Checklist
</DeleteChecklistButton>
</Popup>,
);
}}
onOpenDueDatePopop={(task, $targetRef) => {
showPopup(
$targetRef,
<Popup
title={'Change Due Date'}
tab={0}
onClose={() => {
hidePopup();
}}
>
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
}}
onCancel={() => { }}
/>
</Popup>,
);
}}
/>
);
}}
</Popup>,
);
}}
onOpenDueDatePopop={(task, $targetRef) => {
showPopup(
$targetRef,
<Popup
title={'Change Due Date'}
tab={0}
onClose={() => {
hidePopup();
}}
>
<DueDateManager
task={task}
onRemoveDueDate={t => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
hidePopup();
}}
onDueDateChange={(t, newDueDate) => {
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
hidePopup();
}}
onCancel={() => {}}
/>
</Popup>,
);
}}
/>
</>
);
);
}}
/>
</>
);
};
export default Details;

View File

@ -1,11 +1,19 @@
// LOC830
import React, {useState, useRef, useEffect, useContext} from 'react';
import React, { useState, useRef, useEffect, useContext } from 'react';
import updateApolloCache from 'shared/utils/cache';
import GlobalTopNavbar, {ProjectPopup} from 'App/TopNavbar';
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
import styled from 'styled-components/macro';
import {usePopup, Popup} from 'shared/components/PopupMenu';
import LabelManagerEditor from './LabelManagerEditor'
import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation, Redirect} from 'react-router-dom';
import { usePopup, Popup } from 'shared/components/PopupMenu';
import LabelManagerEditor from './LabelManagerEditor';
import {
useParams,
Route,
useRouteMatch,
useHistory,
RouteComponentProps,
useLocation,
Redirect,
} from 'react-router-dom';
import {
useSetProjectOwnerMutation,
useUpdateProjectMemberRoleMutation,
@ -29,8 +37,20 @@ import produce from 'immer';
import UserIDContext from 'App/context';
import Input from 'shared/components/Input';
import Member from 'shared/components/Member';
import Board from './Board'
import Details from './Details'
import Board from './Board';
import Details from './Details';
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
React.useEffect(() => {
localStorage.setItem(localStorageKey, value);
}, [value]);
return [value, setValue];
};
const SearchInput = styled(Input)`
margin: 0;
@ -55,7 +75,7 @@ type UserManagementPopupProps = {
onAddProjectMember: (userID: string) => void;
};
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({users, projectMembers, onAddProjectMember}) => {
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => {
return (
<Popup tab={0} title="Invite a user">
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
@ -99,7 +119,7 @@ const initialQuickCardEditorState: QuickCardEditorState = {
};
const Project = () => {
const {projectID} = useParams<ProjectParams>();
const { projectID } = useParams<ProjectParams>();
const history = useHistory();
const match = useRouteMatch();
@ -110,14 +130,16 @@ const Project = () => {
console.log(taskLabelsRef.current);
},
});
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
const [deleteTask] = useDeleteTaskMutation();
const [updateTaskName] = useUpdateTaskNameMutation();
const {loading, data} = useFindProjectQuery({
variables: {projectId: projectID},
const { loading, data } = useFindProjectQuery({
variables: { projectId: projectID },
});
const [updateProjectName] = useUpdateProjectNameMutation({
@ -129,7 +151,7 @@ const Project = () => {
produce(cache, draftCache => {
draftCache.findProject.name = newName.data.updateProjectName.name;
}),
{projectId: projectID},
{ projectId: projectID },
);
},
});
@ -141,9 +163,9 @@ const Project = () => {
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.members.push({...response.data.createProjectMember.member});
draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
}),
{projectId: projectID},
{ projectId: projectID },
);
},
});
@ -159,15 +181,15 @@ const Project = () => {
m => m.id !== response.data.deleteProjectMember.member.id,
);
}),
{projectId: projectID},
{ projectId: projectID },
);
},
});
const {userID} = useContext(UserIDContext);
const { userID } = useContext(UserIDContext);
const location = useLocation();
const {showPopup, hidePopup} = usePopup();
const { showPopup, hidePopup } = usePopup();
const $labelsRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<Array<ProjectLabel>>([]);
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
@ -192,25 +214,25 @@ const Project = () => {
<>
<GlobalTopNavbar
onChangeRole={(userID, roleCode) => {
updateProjectMemberRole({variables: {userID, roleCode, projectID}});
updateProjectMemberRole({ variables: { userID, roleCode, projectID } });
}}
onChangeProjectOwner={uid => {
setProjectOwner({variables: {ownerID: uid, projectID}});
setProjectOwner({ variables: { ownerID: uid, projectID } });
hidePopup();
}}
onRemoveFromBoard={userID => {
deleteProjectMember({variables: {userID, projectID}});
deleteProjectMember({ variables: { userID, projectID } });
hidePopup();
}}
onSaveProjectName={projectName => {
updateProjectName({variables: {projectID, name: projectName}});
updateProjectName({ variables: { projectID, name: projectName } });
}}
onInviteUser={$target => {
showPopup(
$target,
<UserManagementPopup
onAddProjectMember={userID => {
createProjectMember({variables: {userID, projectID}});
createProjectMember({ variables: { userID, projectID } });
}}
users={data.users}
projectMembers={data.findProject.members}
@ -218,23 +240,24 @@ const Project = () => {
);
}}
popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />}
menuType={[{name: 'Board', link: location.pathname}]}
menuType={[{ name: 'Board', link: location.pathname }]}
currentTab={0}
projectMembers={data.findProject.members}
projectID={projectID}
name={data.findProject.name}
/>
<Route
path={`${match.path}`}
exact
render={() => (
<Redirect to={`${match.url}/board`} />
)}
/>
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
<Route
path={`${match.path}/board`}
render={() => (
<Board projectID={projectID} />
<Board
cardLabelVariant={value === 'small' ? 'large' : 'small'}
onCardLabelClick={() => {
const variant = value === 'small' ? 'large' : 'small';
setValue(() => variant);
}}
projectID={projectID}
/>
)}
/>
<Route
@ -246,13 +269,13 @@ const Project = () => {
projectURL={`${match.url}/board`}
taskID={routeProps.match.params.taskID}
onTaskNameChange={(updatedTask, newName) => {
updateTaskName({variables: {taskID: updatedTask.id, name: newName}});
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
}}
onTaskDescriptionChange={(updatedTask, newDescription) => {
updateTaskDescription({variables: {taskID: updatedTask.id, description: newDescription}});
updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } });
}}
onDeleteTask={deletedTask => {
deleteTask({variables: {taskID: deletedTask.id}});
deleteTask({ variables: { taskID: deletedTask.id } });
}}
onOpenAddLabelPopup={(task, $targetRef) => {
taskLabelsRef.current = task.labels;
@ -260,7 +283,7 @@ const Project = () => {
$targetRef,
<LabelManagerEditor
onLabelToggle={labelID => {
toggleTaskLabel({variables: {taskID: task.id, projectLabelID: labelID}});
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
}}
labelColors={data.labelColors}
labels={labelsRef}

View File

@ -47,6 +47,7 @@ type TaskUser = {
type RefreshTokenResponse = {
accessToken: string;
isInstalled: boolean;
};
type LoginFormData = {
@ -54,16 +55,41 @@ type LoginFormData = {
password: string;
};
type RegisterFormData = {
username: string;
fullname: string;
email: string;
password: string;
password_confirm: string;
initials: string;
};
type DueDateFormData = {
endDate: string;
endTime: string;
};
type ErrorOption =
| {
types: MultipleFieldErrors;
}
| {
message?: Message;
type: string;
};
type RegisterProps = {
onSubmit: (
data: RegisterFormData,
setComplete: (val: boolean) => void,
setError: (name: 'username' | 'email' | 'password' | 'password_confirm' | 'initials', error: ErrorOption) => void,
) => void;
};
type LoginProps = {
onSubmit: (
data: LoginFormData,
setComplete: (val: boolean) => void,
setError: (field: string, eType: string, message: string) => void,
setError: (name: 'username' | 'password', error: ErrorOption) => void,
) => void;
};
@ -85,3 +111,5 @@ type ElementBounds = {
size: ElementSize;
position: ElementPosition;
};
type CardLabelVariant = 'large' | 'small';

View File

@ -2,13 +2,13 @@ import React from 'react';
import ReactDOM from 'react-dom';
import axios from 'axios';
import createAuthRefreshInterceptor from 'axios-auth-refresh';
import {ApolloProvider} from '@apollo/react-hooks';
import {ApolloClient} from 'apollo-client';
import {InMemoryCache} from 'apollo-cache-inmemory';
import {HttpLink} from 'apollo-link-http';
import {onError} from 'apollo-link-error';
import {ApolloLink, Observable, fromPromise} from 'apollo-link';
import {getAccessToken, getNewToken, setAccessToken} from 'shared/utils/accessToken';
import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
@ -18,7 +18,7 @@ let isRefreshing = false;
let pendingRequests: any = [];
const refreshAuthLogic = (failedRequest: any) =>
axios.post('/auth/refresh_token', {}, {withCredentials: true}).then(tokenRefreshResponse => {
axios.post('/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
setAccessToken(tokenRefreshResponse.data.accessToken);
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
return Promise.resolve();
@ -43,7 +43,7 @@ const setRefreshing = (newVal: boolean) => {
isRefreshing = newVal;
};
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions && err.extensions.code) {
@ -118,9 +118,9 @@ const requestLink = new ApolloLink(
const client = new ApolloClient({
link: ApolloLink.from([
onError(({graphQLErrors, networkError}) => {
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({message, locations, path}) =>
graphQLErrors.forEach(({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
);
}

View File

@ -19,20 +19,20 @@ export const RoleCheckmark = styled(Checkmark)`
`;
const permissions = [
{
code: 'owner',
name: 'Owner',
description:
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.',
},
{
code: 'admin',
name: 'Admin',
description:
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.',
},
{
code: 'owner',
name: 'Owner',
description:
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team. Can delete the team and all team projects.',
},
{
code: 'admin',
name: 'Admin',
description:
'Can view, create and edit team projects, and change settings for the team. Will have admin rights on all projects in this team.',
},
{ code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' },
{ code: 'member', name: 'Member', description: 'Can view, create, and edit team projects, but not change settings.' },
];
export const RoleName = styled.div`
@ -59,13 +59,13 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
text-decoration: none;
${props =>
props.disabled
? css`
props.disabled
? css`
user-select: none;
pointer-events: none;
color: rgba(${props.theme.colors.text.primary}, 0.4);
`
: css`
: css`
cursor: pointer;
&:hover {
background: rgb(115, 103, 240);
@ -104,178 +104,198 @@ export const RemoveMemberButton = styled(Button)`
width: 100%;
`;
type TeamRoleManagerPopupProps = {
user: TaskUser;
warning?: string | null;
canChangeRole: boolean;
onChangeRole: (roleCode: RoleCode) => void;
updateUserPassword?: (user: TaskUser, password: string) => void;
onRemoveFromTeam?: () => void;
user: TaskUser;
warning?: string | null;
canChangeRole: boolean;
onChangeRole: (roleCode: RoleCode) => void;
updateUserPassword?: (user: TaskUser, password: string) => void;
onRemoveFromTeam?: () => void;
};
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
warning,
user,
canChangeRole,
onRemoveFromTeam,
updateUserPassword,
onChangeRole,
warning,
user,
canChangeRole,
onRemoveFromTeam,
updateUserPassword,
onChangeRole,
}) => {
const { hidePopup, setTab } = usePopup();
const [userPass, setUserPass] = useState({ pass: "", passConfirm: "" });
return (
<>
<Popup title={null} tab={0}>
<MiniProfileActions>
<MiniProfileActionWrapper>
{user.role && (
<MiniProfileActionItem
onClick={() => {
setTab(1);
}}
>
Change permissions...
const { hidePopup, setTab } = usePopup();
const [userPass, setUserPass] = useState({ pass: '', passConfirm: '' });
return (
<>
<Popup title={null} tab={0}>
<MiniProfileActions>
<MiniProfileActionWrapper>
{user.role && (
<MiniProfileActionItem
onClick={() => {
setTab(1);
}}
>
Change permissions...
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
</MiniProfileActionItem>
)}
<MiniProfileActionItem onClick={() => {
setTab(3)
}}>Reset password...</MiniProfileActionItem>
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
</MiniProfileActionWrapper>
</MiniProfileActions>
{warning && (
<>
<Separator />
<WarningText>{warning}</WarningText>
</>
)}
</Popup>
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
<MiniProfileActions>
<MiniProfileActionWrapper>
{permissions
.filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner')
.map(perm => (
<MiniProfileActionItem
disabled={user.role && perm.code !== user.role.code && !canChangeRole}
key={perm.code}
onClick={() => {
if (onChangeRole && user.role && perm.code !== user.role.code) {
switch (perm.code) {
case 'owner':
onChangeRole(RoleCode.Owner);
break;
case 'admin':
onChangeRole(RoleCode.Admin);
break;
case 'member':
onChangeRole(RoleCode.Member);
break;
default:
break;
}
hidePopup();
}
}}
>
<RoleName>
{perm.name}
{user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />}
</RoleName>
<RoleDescription>{perm.description}</RoleDescription>
</MiniProfileActionItem>
))}
</MiniProfileActionWrapper>
{user.role && user.role.code === 'owner' && (
<>
<Separator />
<WarningText>You can't change roles because there must be an owner.</WarningText>
</>
)}
</MiniProfileActions>
</Popup>
<Popup title="Remove from Team?" onClose={() => hidePopup()} tab={2}>
<Content>
<DeleteDescription>
The member will be removed from all cards on this project. They will receive a notification.
</MiniProfileActionItem>
)}
<MiniProfileActionItem
onClick={() => {
setTab(3);
}}
>
Reset password...
</MiniProfileActionItem>
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
</MiniProfileActionWrapper>
</MiniProfileActions>
{warning && (
<>
<Separator />
<WarningText>{warning}</WarningText>
</>
)}
</Popup>
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
<MiniProfileActions>
<MiniProfileActionWrapper>
{permissions
.filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner')
.map(perm => (
<MiniProfileActionItem
disabled={user.role && perm.code !== user.role.code && !canChangeRole}
key={perm.code}
onClick={() => {
if (onChangeRole && user.role && perm.code !== user.role.code) {
switch (perm.code) {
case 'owner':
onChangeRole(RoleCode.Owner);
break;
case 'admin':
onChangeRole(RoleCode.Admin);
break;
case 'member':
onChangeRole(RoleCode.Member);
break;
default:
break;
}
hidePopup();
}
}}
>
<RoleName>
{perm.name}
{user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />}
</RoleName>
<RoleDescription>{perm.description}</RoleDescription>
</MiniProfileActionItem>
))}
</MiniProfileActionWrapper>
{user.role && user.role.code === 'owner' && (
<>
<Separator />
<WarningText>You can't change roles because there must be an owner.</WarningText>
</>
)}
</MiniProfileActions>
</Popup>
<Popup title="Remove from Team?" onClose={() => hidePopup()} tab={2}>
<Content>
<DeleteDescription>
The member will be removed from all cards on this project. They will receive a notification.
</DeleteDescription>
<RemoveMemberButton
color="danger"
onClick={() => {
if (onRemoveFromTeam) {
onRemoveFromTeam();
}
}}
>
Remove Member
<RemoveMemberButton
color="danger"
onClick={() => {
if (onRemoveFromTeam) {
onRemoveFromTeam();
}
}}
>
Remove Member
</RemoveMemberButton>
</Content>
</Popup>
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
<Content>
<DeleteDescription>
You can either set the user's new password directly or send the user an email allowing them to reset their own password.
</Content>
</Popup>
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
<Content>
<DeleteDescription>
You can either set the user's new password directly or send the user an email allowing them to reset their
own password.
</DeleteDescription>
<UserPassBar>
<UserPassButton onClick={() => setTab(4)} color="warning">Set password...</UserPassButton>
<UserPassButton color="warning" variant="outline">Send reset link</UserPassButton>
</UserPassBar>
</Content>
</Popup>
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
<Content>
<NewUserPassInput onChange={e => setUserPass({ pass: e.currentTarget.value, passConfirm: userPass.passConfirm })} value={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
<NewUserPassInput onChange={e => setUserPass({ passConfirm: e.currentTarget.value, pass: userPass.pass })} value={userPass.passConfirm} width="100%" variant="alternate" placeholder="New password (confirm)" />
<UserPassConfirmButton disabled={userPass.pass === "" || userPass.passConfirm === ""} onClick={() => {
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
updateUserPassword(user, userPass.pass)
}
}} color="danger">Set password</UserPassConfirmButton>
</Content>
</Popup>
<Popup title="Remove user" onClose={() => hidePopup()} tab={5}>
<Content>
<DeleteDescription>
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
<UserPassBar>
<UserPassButton onClick={() => setTab(4)} color="warning">
Set password...
</UserPassButton>
<UserPassButton color="warning" variant="outline">
Send reset link
</UserPassButton>
</UserPassBar>
</Content>
</Popup>
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
<Content>
<NewUserPassInput defaultValue={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
<NewUserPassInput
defaultValue={userPass.passConfirm}
width="100%"
variant="alternate"
placeholder="New password (confirm)"
/>
<UserPassConfirmButton
disabled={userPass.pass === '' || userPass.passConfirm === ''}
onClick={() => {
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
updateUserPassword(user, userPass.pass);
}
}}
color="danger"
>
Set password
</UserPassConfirmButton>
</Content>
</Popup>
<Popup title="Remove user" onClose={() => hidePopup()} tab={5}>
<Content>
<DeleteDescription>
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
</DeleteDescription>
<DeleteDescription>
The user is the owner of 3 projects & 2 teams.
</DeleteDescription>
<UserSelect onChange={() => { }} value={null} options={[{ label: 'Jordan Knott', value: "jordanknott" }]} />
<UserPassConfirmButton onClick={() => { }} color="danger">Set password</UserPassConfirmButton>
</Content>
</Popup>
</>
);
<DeleteDescription>The user is the owner of 3 projects & 2 teams.</DeleteDescription>
<UserSelect onChange={() => {}} value={null} options={[{ label: 'Jordan Knott', value: 'jordanknott' }]} />
<UserPassConfirmButton onClick={() => {}} color="danger">
Set password
</UserPassConfirmButton>
</Content>
</Popup>
</>
);
};
const UserSelect = styled(Select)`
margin: 8px 0;
padding: 8px 0;
`
margin: 8px 0;
padding: 8px 0;
`;
const NewUserPassInput = styled(Input)`
margin: 8px 0;
`
margin: 8px 0;
`;
const InviteMemberButton = styled(Button)`
padding: 7px 12px;
`;
const UserPassBar = styled.div`
display: flex;
padding-top: 8px;
`
display: flex;
padding-top: 8px;
`;
const UserPassConfirmButton = styled(Button)`
width: 100%;
padding: 7px 12px;
`
`;
const UserPassButton = styled(Button)`
width: 50%;
padding: 7px 12px;
& ~ & {
margin-left: 6px;
margin-left: 6px;
}
`
`;
const MemberItemOptions = styled.div``;
@ -413,7 +433,7 @@ const LockUserIcon = styled(Lock)``;
const DeleteUserIcon = styled(Trash)``;
type ActionButtonProps = {
onClick: ($target: React.RefObject<HTMLElement>) => void;
onClick: ($target: React.RefObject<HTMLElement>) => void;
};
const ActionButtonWrapper = styled.div`
@ -423,84 +443,84 @@ const ActionButtonWrapper = styled.div`
`;
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
const $wrapper = useRef<HTMLDivElement>(null);
return (
<ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}>
{children}
</ActionButtonWrapper>
);
const $wrapper = useRef<HTMLDivElement>(null);
return (
<ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}>
{children}
</ActionButtonWrapper>
);
};
const ActionButtons = (params: any) => {
return (
<>
<ActionButton onClick={() => { }}>
<EditUserIcon width={16} height={16} />
</ActionButton>
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
<DeleteUserIcon width={16} height={16} />
</ActionButton>
</>
);
return (
<>
<ActionButton onClick={() => {}}>
<EditUserIcon width={16} height={16} />
</ActionButton>
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
<DeleteUserIcon width={16} height={16} />
</ActionButton>
</>
);
};
type ListTableProps = {
users: Array<User>;
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
users: Array<User>;
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
};
const ListTable: React.FC<ListTableProps> = ({ users, onDeleteUser }) => {
const data = {
defaultColDef: {
resizable: true,
sortable: true,
const data = {
defaultColDef: {
resizable: true,
sortable: true,
},
columnDefs: [
{
minWidth: 55,
width: 55,
headerCheckboxSelection: true,
checkboxSelection: true,
},
{ minWidth: 210, headerName: 'Username', editable: true, field: 'username' },
{ minWidth: 225, headerName: 'Email', field: 'email' },
{ minWidth: 200, headerName: 'Name', editable: true, field: 'fullName' },
{ minWidth: 200, headerName: 'Role', editable: true, field: 'roleName' },
{
minWidth: 200,
headerName: 'Actions',
field: 'id',
cellRenderer: 'actionButtons',
cellRendererParams: {
onDeleteUser: (target: any, userID: any) => {
onDeleteUser(target, userID);
},
},
columnDefs: [
{
minWidth: 55,
width: 55,
headerCheckboxSelection: true,
checkboxSelection: true,
},
{ minWidth: 210, headerName: 'Username', editable: true, field: 'username' },
{ minWidth: 225, headerName: 'Email', field: 'email' },
{ minWidth: 200, headerName: 'Name', editable: true, field: 'fullName' },
{ minWidth: 200, headerName: 'Role', editable: true, field: 'roleName' },
{
minWidth: 200,
headerName: 'Actions',
field: 'id',
cellRenderer: 'actionButtons',
cellRendererParams: {
onDeleteUser: (target: any, userID: any) => {
onDeleteUser(target, userID);
},
},
},
],
frameworkComponents: {
actionButtons: ActionButtons,
},
};
return (
<Root>
<div className="ag-theme-material" style={{ height: '296px', width: '100%' }}>
<AgGridReact
rowSelection="multiple"
defaultColDef={data.defaultColDef}
columnDefs={data.columnDefs}
rowData={users.map(u => ({ ...u, roleName: u.role.name }))}
frameworkComponents={data.frameworkComponents}
onFirstDataRendered={params => {
params.api.sizeColumnsToFit();
}}
onGridSizeChanged={params => {
params.api.sizeColumnsToFit();
}}
/>
</div>
</Root>
);
},
],
frameworkComponents: {
actionButtons: ActionButtons,
},
};
return (
<Root>
<div className="ag-theme-material" style={{ height: '296px', width: '100%' }}>
<AgGridReact
rowSelection="multiple"
defaultColDef={data.defaultColDef}
columnDefs={data.columnDefs}
rowData={users.map(u => ({ ...u, roleName: u.role.name }))}
frameworkComponents={data.frameworkComponents}
onFirstDataRendered={params => {
params.api.sizeColumnsToFit();
}}
onGridSizeChanged={params => {
params.api.sizeColumnsToFit();
}}
/>
</div>
</Root>
);
};
const Wrapper = styled.div`
@ -604,138 +624,145 @@ const TabContent = styled.div`
const items = [{ name: 'Members' }, { name: 'Settings' }];
type NavItemProps = {
active: boolean;
name: string;
tab: number;
onClick: (tab: number, top: number) => void;
active: boolean;
name: string;
tab: number;
onClick: (tab: number, top: number) => void;
};
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
const $item = useRef<HTMLLIElement>(null);
return (
<TabNavItem
key={name}
ref={$item}
onClick={() => {
if ($item && $item.current) {
const pos = $item.current.getBoundingClientRect();
onClick(tab, pos.top);
}
}}
>
<TabNavItemButton active={active}>
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
<TabNavItemSpan>{name}</TabNavItemSpan>
</TabNavItemButton>
</TabNavItem>
);
const $item = useRef<HTMLLIElement>(null);
return (
<TabNavItem
key={name}
ref={$item}
onClick={() => {
if ($item && $item.current) {
const pos = $item.current.getBoundingClientRect();
onClick(tab, pos.top);
}
}}
>
<TabNavItemButton active={active}>
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
<TabNavItemSpan>{name}</TabNavItemSpan>
</TabNavItemButton>
</TabNavItem>
);
};
type AdminProps = {
initialTab: number;
onAddUser: ($target: React.RefObject<HTMLElement>) => void;
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
users: Array<User>;
onUpdateUserPassword: (user: TaskUser, password: string) => void;
initialTab: number;
onAddUser: ($target: React.RefObject<HTMLElement>) => void;
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
users: Array<User>;
onUpdateUserPassword: (user: TaskUser, password: string) => void;
};
const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPassword, onDeleteUser, onInviteUser, users }) => {
const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
const [currentTop, setTop] = useState(initialTab * 48);
const [currentTab, setTab] = useState(initialTab);
const { showPopup, hidePopup } = usePopup();
const $tabNav = useRef<HTMLDivElement>(null);
const Admin: React.FC<AdminProps> = ({
initialTab,
onAddUser,
onUpdateUserPassword,
onDeleteUser,
onInviteUser,
users,
}) => {
const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
const [currentTop, setTop] = useState(initialTab * 48);
const [currentTab, setTab] = useState(initialTab);
const { showPopup, hidePopup } = usePopup();
const $tabNav = useRef<HTMLDivElement>(null);
const [updateUserRole] = useUpdateUserRoleMutation()
return (
<Container>
<TabNav ref={$tabNav}>
<TabNavContent>
{items.map((item, idx) => (
<NavItem
onClick={(tab, top) => {
if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect();
setTab(tab);
setTop(top - pos.top);
}
}}
name={item.name}
tab={idx}
active={idx === currentTab}
/>
))}
<TabNavLine top={currentTop} />
</TabNavContent>
</TabNav>
<TabContentWrapper>
<TabContent>
<MemberListWrapper>
<MemberListHeader>
<ListTitle>{`Users (${users.length})`}</ListTitle>
<ListDesc>
Team members can view and join all Team Visible boards and create new boards in the team.
const [updateUserRole] = useUpdateUserRoleMutation();
return (
<Container>
<TabNav ref={$tabNav}>
<TabNavContent>
{items.map((item, idx) => (
<NavItem
onClick={(tab, top) => {
if ($tabNav && $tabNav.current) {
const pos = $tabNav.current.getBoundingClientRect();
setTab(tab);
setTop(top - pos.top);
}
}}
name={item.name}
tab={idx}
active={idx === currentTab}
/>
))}
<TabNavLine top={currentTop} />
</TabNavContent>
</TabNav>
<TabContentWrapper>
<TabContent>
<MemberListWrapper>
<MemberListHeader>
<ListTitle>{`Users (${users.length})`}</ListTitle>
<ListDesc>
Team members can view and join all Team Visible boards and create new boards in the team.
</ListDesc>
<ListActions>
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
<InviteMemberButton
onClick={$target => {
onAddUser($target);
}}
>
<InviteIcon width={16} height={16} />
New Member
<ListActions>
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
<InviteMemberButton
onClick={$target => {
onAddUser($target);
}}
>
<InviteIcon width={16} height={16} />
New Member
</InviteMemberButton>
</ListActions>
</MemberListHeader>
<MemberList>
{users.map(member => (
<MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={() => { }} member={member} />
<MemberListItemDetails>
<MemberItemName>{member.fullName}</MemberItemName>
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
</MemberListItemDetails>
<MemberItemOptions>
<MemberItemOption variant="flat">On 6 projects</MemberItemOption>
<MemberItemOption
variant="outline"
onClick={$target => {
showPopup(
$target,
<TeamRoleManagerPopup
user={member}
warning={member.role && member.role.code === 'owner' ? warning : null}
updateUserPassword={(user, password) => {
onUpdateUserPassword(user, password)
}}
canChangeRole={member.role && member.role.code !== 'owner'}
onChangeRole={roleCode => {
updateUserRole({ variables: { userID: member.id, roleCode } })
}}
onRemoveFromTeam={
member.role && member.role.code === 'owner'
? undefined
: () => {
hidePopup();
}
}
/>,
);
}}
>
Manage
</ListActions>
</MemberListHeader>
<MemberList>
{users.map(member => (
<MemberListItem>
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
<MemberListItemDetails>
<MemberItemName>{member.fullName}</MemberItemName>
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
</MemberListItemDetails>
<MemberItemOptions>
<MemberItemOption variant="flat">On 6 projects</MemberItemOption>
<MemberItemOption
variant="outline"
onClick={$target => {
showPopup(
$target,
<TeamRoleManagerPopup
user={member}
warning={member.role && member.role.code === 'owner' ? warning : null}
updateUserPassword={(user, password) => {
onUpdateUserPassword(user, password);
}}
canChangeRole={member.role && member.role.code !== 'owner'}
onChangeRole={roleCode => {
updateUserRole({ variables: { userID: member.id, roleCode } });
}}
onRemoveFromTeam={
member.role && member.role.code === 'owner'
? undefined
: () => {
hidePopup();
}
}
/>,
);
}}
>
Manage
</MemberItemOption>
</MemberItemOptions>
</MemberListItem>
))}
</MemberList>
</MemberListWrapper>
</TabContent>
</TabContentWrapper>
</Container>
);
</MemberItemOptions>
</MemberListItem>
))}
</MemberList>
</MemberListWrapper>
</TabContent>
</TabContentWrapper>
</Container>
);
};
export default Admin;

View File

@ -1,9 +1,9 @@
import styled, {css} from 'styled-components';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {mixin} from 'shared/utils/styles';
import styled, { css, keyframes } from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles';
import TextareaAutosize from 'react-autosize-textarea';
import {CheckCircle} from 'shared/icons';
import {RefObject} from 'react';
import { CheckCircle } from 'shared/icons';
import { RefObject } from 'react';
export const ClockIcon = styled(FontAwesomeIcon)``;
@ -57,7 +57,7 @@ export const DescriptionBadge = styled(ListCardBadge)`
padding-right: 6px;
`;
export const DueDateCardBadge = styled(ListCardBadge) <{isPastDue: boolean}>`
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
font-size: 12px;
${props =>
props.isPastDue &&
@ -76,7 +76,7 @@ export const ListCardBadgeText = styled.span`
white-space: nowrap;
`;
export const ListCardContainer = styled.div<{isActive: boolean; editable: boolean}>`
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
max-width: 256px;
margin-bottom: 8px;
border-radius: 3px;
@ -93,7 +93,7 @@ export const ListCardInnerContainer = styled.div`
height: 100%;
`;
export const ListCardDetails = styled.div<{complete: boolean}>`
export const ListCardDetails = styled.div<{ complete: boolean }>`
overflow: hidden;
padding: 6px 8px 2px;
position: relative;
@ -102,28 +102,93 @@ export const ListCardDetails = styled.div<{complete: boolean}>`
${props => props.complete && 'opacity: 0.6;'}
`;
export const ListCardLabels = styled.div`
overflow: auto;
position: relative;
const labelVariantExpandAnimation = keyframes`
0% {min-width: 40px; height: 8px;}
50% {min-width: 56px; height: 8px;}
100% {min-width: 56px; height: 16px;}
`;
export const ListCardLabel = styled.span`
height: 16px;
const labelTextVariantExpandAnimation = keyframes`
0% {transform: scale(0); visibility: hidden; pointer-events: none;}
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
100% {transform: scale(1); visibility: visible; pointer-events: all;}
`;
const labelVariantShrinkAnimation = keyframes`
0% {min-width: 56px; height: 16px;}
50% {min-width: 56px; height: 8px;}
100% {min-width: 40px; height: 8px;}
`;
const labelTextVariantShrinkAnimation = keyframes`
0% {transform: scale(1); visibility: visible; pointer-events: all;}
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
100% {transform: scale(0); visibility: hidden; pointer-events: none;}
`;
export const ListCardLabelText = styled.span`
font-size: 12px;
font-weight: 700;
line-height: 16px;
`;
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
${props =>
props.variant === 'small'
? css`
height: 8px;
min-width: 40px;
& ${ListCardLabelText} {
transform: scale(0);
visibility: hidden;
pointer-events: none;
}
`
: css`
height: 16px;
min-width: 56px;
`}
padding: 0 8px;
max-width: 198px;
float: left;
font-size: 12px;
font-weight: 700;
margin: 0 4px 4px 0;
width: auto;
border-radius: 4px;
color: #fff;
display: block;
display: flex;
position: relative;
background-color: ${props => props.color};
`;
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
overflow: auto;
position: relative;
&:hover {
opacity: 0.8;
}
${props =>
props.toggleLabels &&
props.toggleDirection === 'expand' &&
css`
& ${ListCardLabel} {
animation: ${labelVariantExpandAnimation} 0.45s ease-out;
}
& ${ListCardLabelText} {
animation: ${labelTextVariantExpandAnimation} 0.45s ease-out;
}
`}
${props =>
props.toggleLabels &&
props.toggleDirection === 'shrink' &&
css`
& ${ListCardLabel} {
animation: ${labelVariantShrinkAnimation} 0.45s ease-out;
}
& ${ListCardLabelText} {
animation: ${labelTextVariantShrinkAnimation} 0.45s ease-out;
}
`}
`;
export const ListCardOperation = styled.span`
display: flex;
align-content: center;
@ -136,7 +201,7 @@ export const ListCardOperation = styled.span`
top: 2px;
z-index: 100;
&:hover {
background-color: ${props => mixin.darken('#262c49', .25)};
background-color: ${props => mixin.darken('#262c49', 0.25)};
}
`;

View File

@ -1,8 +1,8 @@
import React, {useState, useRef, useEffect} from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import React, { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import TaskAssignee from 'shared/components/TaskAssignee';
import {faPencilAlt, faList} from '@fortawesome/free-solid-svg-icons';
import {faClock, faCheckSquare, faEye} from '@fortawesome/free-regular-svg-icons';
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
import {
EditorTextarea,
EditorContent,
@ -18,6 +18,7 @@ import {
ClockIcon,
ListCardLabels,
ListCardLabel,
ListCardLabelText,
ListCardOperation,
CardTitle,
CardMembers,
@ -47,10 +48,12 @@ type Props = {
watched?: boolean;
wrapperProps?: any;
members?: Array<TaskUser> | null;
onCardLabelClick?: () => void;
onCardMemberClick?: OnCardMemberClick;
editable?: boolean;
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
onCardTitleChange?: (name: string) => void;
labelVariant?: CardLabelVariant;
};
const Card = React.forwardRef(
@ -69,14 +72,20 @@ const Card = React.forwardRef(
checklists,
watched,
members,
labelVariant,
onCardMemberClick,
editable,
onCardLabelClick,
onEditCard,
onCardTitleChange,
}: Props,
$cardRef: any,
) => {
const [currentCardTitle, setCardTitle] = useState(title);
const [toggleLabels, setToggleLabels] = useState(false);
const [toggleDirection, setToggleDirection] = useState<'shrink' | 'expand'>(
labelVariant === 'large' ? 'shrink' : 'expand',
);
const $editorRef: any = useRef();
useEffect(() => {
@ -132,21 +141,39 @@ const Card = React.forwardRef(
>
<ListCardInnerContainer ref={$innerCardRef}>
{isActive && (
<ListCardOperation onClick={e => {
e.stopPropagation();
if (onContextMenu) {
onContextMenu($innerCardRef, taskID, taskGroupID);
}
}}>
<ListCardOperation
onClick={e => {
e.stopPropagation();
if (onContextMenu) {
onContextMenu($innerCardRef, taskID, taskGroupID);
}
}}
>
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
</ListCardOperation>
)}
<ListCardDetails complete={complete ?? false}>
<ListCardLabels>
<ListCardLabels
toggleLabels={toggleLabels}
toggleDirection={toggleDirection}
onClick={e => {
e.stopPropagation();
if (onCardLabelClick) {
setToggleLabels(true);
setToggleDirection(labelVariant === 'large' ? 'shrink' : 'expand');
onCardLabelClick();
}
}}
>
{labels &&
labels.map(label => (
<ListCardLabel color={label.labelColor.colorHex} key={label.id}>
{label.name}
<ListCardLabel
onAnimationEnd={() => setToggleLabels(false)}
variant={labelVariant ?? 'large'}
color={label.labelColor.colorHex}
key={label.id}
>
<ListCardLabelText>{label.name}</ListCardLabelText>
</ListCardLabel>
))}
</ListCardLabels>
@ -169,11 +196,11 @@ const Card = React.forwardRef(
/>
</EditorContent>
) : (
<CardTitle>
{complete && <CompleteIcon width={16} height={16} />}
{title}
</CardTitle>
)}
<CardTitle>
{complete && <CompleteIcon width={16} height={16} />}
{title}
</CardTitle>
)}
<ListCardBadges>
{watched && (
<ListCardBadge>

View File

@ -17,7 +17,7 @@ import {
import 'react-datepicker/dist/react-datepicker.css';
import { getYear, getMonth } from 'date-fns';
import { useForm } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
type DueDateManagerProps = {
task: Task;
@ -147,7 +147,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
'November',
'December',
];
const { register, handleSubmit, errors, setValue, setError, formState } = useForm<DueDateFormData>();
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
const saveDueDate = (data: any) => {
console.log(data);
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
@ -155,27 +155,16 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
onDueDateChange(task, newDate.toDate());
}
};
console.log(errors);
register({ name: 'endTime' }, { required: 'End time is required' });
useEffect(() => {
setValue('endTime', now.format('h:mm A'));
}, []);
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
return (
<DueDateInput
id="endTime"
name="endTime"
ref={$ref}
onChange={e => {
console.log(`onCahnge ${e.currentTarget.value}`);
setTextEndTime(e.currentTarget.value);
setValue('endTime', e.currentTarget.value);
}}
width="100%"
variant="alternate"
label="Date"
onClick={onClick}
value={value}
/>
);
});
@ -190,30 +179,29 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
width="100%"
variant="alternate"
label="Date"
onChange={e => {
setTextStartDate(e.currentTarget.value);
}}
value={textStartDate}
defaultValue={textStartDate}
ref={register({
required: 'End date is required.',
})}
/>
</FormField>
<FormField>
<DatePicker
selected={endTime}
onChange={date => {
const changedDate = moment(date ?? new Date());
console.log(`changed ${date}`);
setEndTime(changedDate.toDate());
setValue('endTime', changedDate.format('h:mm A'));
}}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Time"
dateFormat="h:mm aa"
customInput={<CustomTimeInput />}
<Controller
control={control}
name="endTime"
render={({ onChange, onBlur, value }) => (
<DatePicker
onChange={onChange}
selected={value}
onBlur={onBlur}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Time"
dateFormat="h:mm aa"
customInput={<CustomTimeInput />}
/>
)}
/>
</FormField>
<DueDatePickerWrapper>

View File

@ -1,7 +1,7 @@
import React, {useState, useEffect} from 'react';
import styled, {css} from 'styled-components/macro';
import React, { useState, useEffect } from 'react';
import styled, { css } from 'styled-components/macro';
const InputWrapper = styled.div<{width: string}>`
const InputWrapper = styled.div<{ width: string }>`
position: relative;
width: ${props => props.width};
display: flex;
@ -14,7 +14,7 @@ const InputWrapper = styled.div<{width: string}>`
margin-top: 24px;
`;
const InputLabel = styled.span<{width: string}>`
const InputLabel = styled.span<{ width: string }>`
width: ${props => props.width};
padding: 0.7rem !important;
color: #c2c6dc;
@ -87,9 +87,8 @@ type InputProps = {
id?: string;
name?: string;
className?: string;
value?: string;
defaultValue?: string;
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
const Input = React.forwardRef(
@ -102,40 +101,34 @@ const Input = React.forwardRef(
placeholder,
icon,
name,
onChange,
className,
onClick,
floatingLabel,
value: initialValue,
defaultValue,
id,
}: InputProps,
$ref: any,
) => {
const [value, setValue] = useState(initialValue ?? '');
useEffect(() => {
if (initialValue) {
setValue(initialValue);
}
}, [initialValue]);
const [hasValue, setHasValue] = useState(false);
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value);
if (onChange) {
onChange(e);
}
};
return (
<InputWrapper className={className} width={width}>
<InputInput
hasValue={floatingLabel || value !== ''}
onChange={() => {
console.log(`change ${$ref}!`);
if ($ref && $ref.current) {
console.log(`value : ${$ref.current.value}`);
setHasValue(($ref.current.value !== '' || floatingLabel) ?? false);
}
}}
hasValue={hasValue}
ref={$ref}
id={id}
name={name}
onClick={onClick}
onChange={handleChange}
autoComplete={autocomplete ? 'on' : 'off'}
value={value}
defaultValue={defaultValue}
hasIcon={typeof icon !== 'undefined'}
width={width}
placeholder={placeholder}

View File

@ -93,6 +93,8 @@ export const Default = () => {
onTaskDrop={onCardDrop}
onTaskGroupDrop={onListDrop}
onChangeTaskGroupName={action('change group name')}
cardLabelVariant="large"
onCardLabelClick={action('label click')}
onCreateTaskGroup={action('create list')}
onExtraMenuOpen={action('extra menu open')}
onCardMemberClick={action('card member click')}

View File

@ -26,17 +26,21 @@ interface SimpleProps {
onCreateTaskGroup: (listName: string) => void;
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
onCardMemberClick: OnCardMemberClick;
onCardLabelClick: () => void;
cardLabelVariant: CardLabelVariant;
}
const SimpleLists: React.FC<SimpleProps> = ({
taskGroups,
onTaskDrop,
onChangeTaskGroupName,
onCardLabelClick,
onTaskGroupDrop,
onTaskClick,
onCreateTask,
onQuickEditorOpen,
onCreateTaskGroup,
cardLabelVariant,
onExtraMenuOpen,
onCardMemberClick,
}) => {
@ -158,10 +162,12 @@ const SimpleLists: React.FC<SimpleProps> = ({
{taskProvided => {
return (
<Card
labelVariant={cardLabelVariant}
wrapperProps={{
...taskProvided.draggableProps,
...taskProvided.dragHandleProps,
}}
onCardLabelClick={onCardLabelClick}
ref={taskProvided.innerRef}
taskID={task.id}
complete={task.complete ?? false}

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Checkmark } from 'shared/icons';
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
import styled from 'styled-components';
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
const WhiteCheckmark = styled(Checkmark)`
fill: rgba(${props => props.theme.colors.text.secondary});
@ -39,17 +39,19 @@ const LabelManager = ({ labelColors, label, onLabelEdit, onLabelDelete }: Props)
/>
<FieldLabel>Select a color</FieldLabel>
<div>
{labelColors.map((labelColor: LabelColor) => (
<LabelBox
key={labelColor.id}
color={labelColor.colorHex}
onClick={() => {
setCurrentColor(labelColor);
}}
>
{currentColor && labelColor.id === currentColor.id && <WhiteCheckmark width={12} height={12} />}
</LabelBox>
))}
{labelColors
.filter(l => l.name !== 'no_color')
.map((labelColor: LabelColor) => (
<LabelBox
key={labelColor.id}
color={labelColor.colorHex}
onClick={() => {
setCurrentColor(labelColor);
}}
>
{currentColor && labelColor.id === currentColor.id && <WhiteCheckmark width={12} height={12} />}
</LabelBox>
))}
</div>
<div>
<SaveButton

View File

@ -40,7 +40,7 @@ export const ListSeparator = styled.hr`
type Props = {
onDeleteProject: () => void;
};
const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
const ProjectSettings: React.FC<Props> = ({onDeleteProject}) => {
return (
<>
<ListActionsWrapper>
@ -55,7 +55,7 @@ const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
type TeamSettingsProps = {
onDeleteTeam: () => void;
};
export const TeamSettings: React.FC<TeamSettingsProps> = ({ onDeleteTeam }) => {
export const TeamSettings: React.FC<TeamSettingsProps> = ({onDeleteTeam}) => {
return (
<>
<ListActionsWrapper>
@ -74,6 +74,7 @@ const ConfirmSubTitle = styled.h3`
`;
const ConfirmDescription = styled.div`
margin: 0 12px;
font-size: 14px;
`;
@ -83,7 +84,7 @@ const DeleteList = styled.ul`
const DeleteListItem = styled.li`
padding: 6px 0;
list-style: disc;
margin-left: 12px;
margin-left: 16px;
`;
const ConfirmDeleteButton = styled(Button)`
@ -108,7 +109,7 @@ export const DELETE_INFO = {
},
};
const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems, onConfirmDelete }) => {
const DeleteConfirm: React.FC<DeleteConfirmProps> = ({description, deletedItems, onConfirmDelete}) => {
return (
<ConfirmWrapper>
<ConfirmDescription>
@ -126,5 +127,5 @@ const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems
);
};
export { DeleteConfirm };
export {DeleteConfirm};
export default ProjectSettings;

View File

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

View File

@ -0,0 +1,150 @@
import React, { useState } from 'react';
import AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock, Citadel } from 'shared/icons';
import { useForm } from 'react-hook-form';
import {
Form,
LogoWrapper,
LogoTitle,
ActionButtons,
RegisterButton,
FormError,
FormIcon,
FormLabel,
FormTextInput,
Wrapper,
Column,
LoginFormWrapper,
LoginFormContainer,
Title,
SubTitle,
} from './Styles';
const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
const INITIALS_PATTERN = /[a-zA-Z]{2,3}/i;
const Register = ({ onSubmit }: RegisterProps) => {
const [isComplete, setComplete] = useState(true);
const { register, handleSubmit, errors, setError } = useForm<RegisterFormData>();
const loginSubmit = (data: RegisterFormData) => {
setComplete(false);
onSubmit(data, setComplete, setError);
};
return (
<Wrapper>
<Column>
<AccessAccount width={275} height={250} />
</Column>
<Column>
<LoginFormWrapper>
<LoginFormContainer>
<LogoWrapper>
<Citadel width={42} height={42} />
<LogoTitle>Citadel</LogoTitle>
</LogoWrapper>
<Title>Register</Title>
<SubTitle>Please create the system admin user</SubTitle>
<Form onSubmit={handleSubmit(loginSubmit)}>
<FormLabel htmlFor="fullname">
Full name
<FormTextInput
type="text"
id="fullname"
name="fullname"
ref={register({ required: 'Full name is required' })}
/>
<FormIcon>
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="username">
Username
<FormTextInput
type="text"
id="username"
name="username"
ref={register({ required: 'Username is required' })}
/>
<FormIcon>
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.username && <FormError>{errors.username.message}</FormError>}
<FormLabel htmlFor="email">
Email
<FormTextInput
type="text"
id="email"
name="email"
ref={register({
required: 'Email is required',
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
})}
/>
<FormIcon>
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.email && <FormError>{errors.email.message}</FormError>}
<FormLabel htmlFor="initials">
Initials
<FormTextInput
type="text"
id="initials"
name="initials"
ref={register({
required: 'Initials is required',
pattern: {
value: INITIALS_PATTERN,
message: 'Initials must be between 2 to 3 characters.',
},
})}
/>
<FormIcon>
<User color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.initials && <FormError>{errors.initials.message}</FormError>}
<FormLabel htmlFor="password">
Password
<FormTextInput
type="password"
id="password"
name="password"
ref={register({ required: 'Password is required' })}
/>
<FormIcon>
<Lock width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.password && <FormError>{errors.password.message}</FormError>}
<FormLabel htmlFor="password_confirm">
Password (Confirm)
<FormTextInput
type="password"
id="password_confirm"
name="password_confirm"
ref={register({ required: 'Password (confirm) is required' })}
/>
<FormIcon>
<Lock width={20} height={20} />
</FormIcon>
</FormLabel>
{errors.password_confirm && <FormError>{errors.password_confirm.message}</FormError>}
<ActionButtons>
<RegisterButton type="submit" disabled={!isComplete}>
Register
</RegisterButton>
</ActionButtons>
</Form>
</LoginFormContainer>
</LoginFormWrapper>
</Column>
</Wrapper>
);
};
export default Register;

View File

@ -263,13 +263,13 @@ const Settings: React.FC<SettingsProps> = ({ onProfileAvatarRemove, onProfileAva
onProfileAvatarChange={onProfileAvatarChange}
profile={profile.profileIcon}
/>
<Input value={profile.fullName} width="100%" label="Name" />
<Input defaultValue={profile.fullName} width="100%" label="Name" />
<Input
value={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
width="100%"
label="Initials "
/>
<Input value={profile.username ?? ''} width="100%" label="Username " />
<Input defaultValue={profile.username ?? ''} width="100%" label="Username " />
<Input width="100%" label="Email" />
<Input width="100%" label="Bio" />
<SettingActions>

View File

@ -267,6 +267,7 @@ export type Mutation = {
updateTaskLocation: UpdateTaskLocationPayload;
updateTaskName: Task;
updateTeamMemberRole: UpdateTeamMemberRolePayload;
updateUserPassword: UpdateUserPasswordPayload;
updateUserRole: UpdateUserRolePayload;
};
@ -506,6 +507,11 @@ export type MutationUpdateTeamMemberRoleArgs = {
};
export type MutationUpdateUserPasswordArgs = {
input: UpdateUserPassword;
};
export type MutationUpdateUserRoleArgs = {
input: UpdateUserRole;
};
@ -862,6 +868,17 @@ export type SetTeamOwnerPayload = {
newOwner: Member;
};
export type UpdateUserPassword = {
userID: Scalars['UUID'];
password: Scalars['String'];
};
export type UpdateUserPasswordPayload = {
__typename?: 'UpdateUserPasswordPayload';
ok: Scalars['Boolean'];
user: UserAccount;
};
export type UpdateUserRole = {
userID: Scalars['UUID'];
roleCode: RoleCode;

View File

@ -13581,10 +13581,10 @@ react-helmet-async@^1.0.2:
react-fast-compare "^2.0.4"
shallowequal "^1.1.0"
react-hook-form@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.2.0.tgz#b5b654516ee03d55d78b7b9e194c7f4632885426"
integrity sha512-EqGCSl3DxSUBtL/9lFvrFQLJ7ICdVKrfjcMHay2SvmU4trR8aqrd7YuiLSojBKmZBRdBnCcxG+LzLWF9z474NA==
react-hook-form@^6.0.6:
version "6.0.6"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.0.6.tgz#72ac1668aeaddfd642bcfb324cebe1ba237fb13e"
integrity sha512-qxWhV++1V7SKKlr2hHFsessGwATCdexgVsByxOHltDyO9F0VWB1WN4ZvxnKuHTVGwjj6CLZo0mL+Hgy0QH1sAw==
react-hotkeys@2.0.0:
version "2.0.0"

View File

@ -9,8 +9,16 @@ import (
var jwtKey = []byte("citadel_test_key")
type RestrictedMode string
const (
Unrestricted RestrictedMode = "unrestricted"
InstallOnly = "install_only"
)
type AccessTokenClaims struct {
UserID string `json:"userId"`
UserID string `json:"userId"`
Restricted RestrictedMode `json:"restricted"`
jwt.StandardClaims
}
@ -31,10 +39,11 @@ func (r *ErrMalformedToken) Error() string {
return "token is malformed"
}
func NewAccessToken(userID string) (string, error) {
func NewAccessToken(userID string, restrictedMode RestrictedMode) (string, error) {
accessExpirationTime := time.Now().Add(5 * time.Second)
accessClaims := &AccessTokenClaims{
UserID: userID,
Restricted: restrictedMode,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
}
@ -50,6 +59,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
accessExpirationTime := time.Now().Add(dur)
accessClaims := &AccessTokenClaims{
UserID: userID,
Restricted: Unrestricted,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
}

View File

@ -58,6 +58,12 @@ type Role struct {
Name string `json:"name"`
}
type SystemOption struct {
OptionID uuid.UUID `json:"option_id"`
Key string `json:"key"`
Value sql.NullString `json:"value"`
}
type Task struct {
TaskID uuid.UUID `json:"task_id"`
TaskGroupID uuid.UUID `json:"task_group_id"`

View File

@ -15,6 +15,7 @@ type Querier interface {
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
@ -61,6 +62,7 @@ type Querier interface {
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error)
GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error)
GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error)
GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error)
GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error)

View File

@ -0,0 +1,5 @@
-- name: GetSystemOptionByKey :one
SELECT key, value FROM system_options WHERE key = $1;
-- name: CreateSystemOption :one
INSERT INTO system_options (key, value) VALUES ($1, $2) RETURNING *;

View File

@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// source: system_options.sql
package db
import (
"context"
"database/sql"
)
const createSystemOption = `-- name: CreateSystemOption :one
INSERT INTO system_options (key, value) VALUES ($1, $2) RETURNING option_id, key, value
`
type CreateSystemOptionParams struct {
Key string `json:"key"`
Value sql.NullString `json:"value"`
}
func (q *Queries) CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error) {
row := q.db.QueryRowContext(ctx, createSystemOption, arg.Key, arg.Value)
var i SystemOption
err := row.Scan(&i.OptionID, &i.Key, &i.Value)
return i, err
}
const getSystemOptionByKey = `-- name: GetSystemOptionByKey :one
SELECT key, value FROM system_options WHERE key = $1
`
type GetSystemOptionByKeyRow struct {
Key string `json:"key"`
Value sql.NullString `json:"value"`
}
func (q *Queries) GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error) {
row := q.db.QueryRowContext(ctx, getSystemOptionByKey, key)
var i GetSystemOptionByKeyRow
err := row.Scan(&i.Key, &i.Value)
return i, err
}

View File

@ -12,6 +12,7 @@ import (
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/google/uuid"
"github.com/jordanknott/project-citadel/api/internal/auth"
"github.com/jordanknott/project-citadel/api/internal/config"
"github.com/jordanknott/project-citadel/api/internal/db"
)
@ -49,7 +50,13 @@ func NewHandler(config config.AppConfig, repo db.Repository) http.Handler {
func NewPlaygroundHandler(endpoint string) http.Handler {
return playground.Handler("GraphQL Playground", endpoint)
}
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
userID, ok := ctx.Value("userID").(uuid.UUID)
return userID, ok
}
func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) {
restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode)
return restricted, ok
}

View File

@ -706,6 +706,7 @@ func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTea
if err != nil {
return &DeleteTeamMemberPayload{}, err
}
_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID})
if err != nil {
return &DeleteTeamMemberPayload{}, err
@ -992,7 +993,10 @@ func (r *queryResolver) Me(ctx context.Context) (*db.UserAccount, error) {
return &db.UserAccount{}, fmt.Errorf("internal server error")
}
user, err := r.Repository.GetUserAccountByID(ctx, userID)
if err != nil {
if err == sql.ErrNoRows {
log.WithFields(log.Fields{"userID": userID}).Warning("can not find user for me query")
return &db.UserAccount{}, nil
} else if err != nil {
return &db.UserAccount{}, err
}
return &user, err

View File

@ -1,11 +1,11 @@
package route
import (
"database/sql"
"encoding/json"
"net/http"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/go-chi/chi"
"github.com/google/uuid"
"github.com/jordanknott/project-citadel/api/internal/auth"
@ -18,23 +18,26 @@ var jwtKey = []byte("citadel_test_key")
type authResource struct{}
type AccessTokenClaims struct {
UserID string `json:"userId"`
jwt.StandardClaims
}
type RefreshTokenClaims struct {
UserID string `json:"userId"`
jwt.StandardClaims
}
type LoginRequestData struct {
Username string
Password string
}
type NewUserAccount struct {
FullName string `json:"fullname"`
Username string
Password string
Initials string
Email string
}
type InstallRequestData struct {
User NewUserAccount
}
type LoginResponseData struct {
AccessToken string `json:"accessToken"`
IsInstalled bool `json:"isInstalled"`
}
type LogoutResponseData struct {
@ -51,18 +54,48 @@ type AvatarUploadResponseData struct {
}
func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
if err == sql.ErrNoRows {
user, err := h.repo.GetUserAccountByUsername(r.Context(), "system")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.Header().Set("Content-type", "application/json")
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: false})
return
} else if err != nil {
log.WithError(err).Error("get system option")
w.WriteHeader(http.StatusBadRequest)
return
}
c, err := r.Cookie("refreshToken")
if err != nil {
if err == http.ErrNoCookie {
w.WriteHeader(http.StatusBadRequest)
return
}
log.WithError(err).Error("unknown error")
w.WriteHeader(http.StatusBadRequest)
return
}
refreshTokenID := uuid.MustParse(c.Value)
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
if err != nil {
if err == sql.ErrNoRows {
log.WithError(err).WithFields(log.Fields{"refreshTokenID": refreshTokenID.String()}).Error("no tokens found")
w.WriteHeader(http.StatusBadRequest)
return
}
log.WithError(err).Error("token retrieve failure")
w.WriteHeader(http.StatusBadRequest)
return
}
@ -76,7 +109,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
w.WriteHeader(http.StatusInternalServerError)
}
accessTokenString, err := auth.NewAccessToken(token.UserID.String())
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -88,7 +121,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: true})
}
func (h *CitadelHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
@ -142,7 +175,7 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String())
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
@ -154,7 +187,68 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString})
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
func (h *CitadelHandler) InstallHandler(w http.ResponseWriter, r *http.Request) {
if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok {
if restricted != auth.InstallOnly {
log.Warning("attempted to install without install only restriction")
w.WriteHeader(http.StatusBadRequest)
return
}
}
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
if err != sql.ErrNoRows {
log.WithError(err).Error("install handler called even though system is installed")
w.WriteHeader(http.StatusBadRequest)
return
}
var requestData InstallRequestData
err = json.NewDecoder(r.Body).Decode(&requestData)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
log.WithFields(log.Fields{"r": requestData}).Info("install")
createdAt := time.Now().UTC()
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{
Username: requestData.User.Username,
Initials: requestData.User.Initials,
Email: requestData.User.Email,
PasswordHash: string(hashedPwd),
CreatedAt: createdAt,
RoleCode: "admin",
})
_, err = h.repo.CreateSystemOption(r.Context(), db.CreateSystemOptionParams{Key: "is_installed", Value: sql.NullString{Valid: true, String: "true"}})
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{
Name: "refreshToken",
Value: refreshTokenString.TokenID.String(),
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router {

View File

@ -40,13 +40,19 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
return
}
userID, err := uuid.Parse(accessClaims.UserID)
if err != nil {
log.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
var userID uuid.UUID
if accessClaims.Restricted == auth.InstallOnly {
userID = uuid.New()
} else {
userID, err = uuid.Parse(accessClaims.UserID)
if err != nil {
log.WithError(err).Error("middleware access token userID parse")
w.WriteHeader(http.StatusBadRequest)
return
}
}
ctx := context.WithValue(r.Context(), "userID", userID)
ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted)
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@ -104,6 +104,7 @@ func NewRouter(config config.AppConfig, dbConnection *sqlx.DB) (chi.Router, erro
r.Group(func(mux chi.Router) {
mux.Use(AuthenticationMiddleware)
mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload)
mux.Post("/auth/install", citadelHandler.InstallHandler)
mux.Handle("/graphql", graph.NewHandler(config, *repository))
})

View File

@ -0,0 +1,5 @@
CREATE TABLE system_options (
option_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
key text NOT NULL UNIQUE,
value text
);

View File

@ -0,0 +1,2 @@
INSERT INTO user_account(created_at, email, initials, username, full_name,
role_code, password_hash) VALUES (NOW(), '', 'SYS', 'system', 'System', 'owner', '');

View File

@ -0,0 +1,16 @@
INSERT INTO label_color (color_hex, position, name ) VALUES ( 'transparent', 0.0, 'no_color' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#e8384f', 1.0, 'red' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fd612c', 2.0, 'orange' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fd9a00', 3.0, 'yellow_orange' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#eec300', 4.0, 'yellow' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#a4cf30', 5.0, 'yellow_green' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#62d26f', 6.0, 'green' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#37c5ab', 6.0, 'blue_green' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#20aaea', 6.0, 'aqua' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#4186e0', 6.0, 'blue' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#7a6ff0', 6.0, 'indigo' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#aa62e3', 6.0, 'purple' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#e362e3', 6.0, 'magenta' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#ea4e9d', 6.0, 'hot_pink' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fc91ad', 6.0, 'pink' );
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#8da3a6', 6.0, 'cool_gray' );

View File

@ -0,0 +1,6 @@
ALTER TABLE task_group DROP CONSTRAINT task_group_project_id_fkey;
ALTER TABLE task_group
ADD CONSTRAINT task_group_project_id_fkey
FOREIGN KEY (project_id)
REFERENCES project(project_id)
ON DELETE CASCADE;

View File

@ -0,0 +1,27 @@
+--------------------+--------------------------+--------------------------------------+
| Column | Type | Modifiers |
|--------------------+--------------------------+--------------------------------------|
| user_id | uuid | not null default uuid_generate_v4() |
| created_at | timestamp with time zone | not null |
| email | text | not null |
| username | text | not null |
| password_hash | text | not null |
| profile_bg_color | text | not null default '#7367F0'::text |
| full_name | text | not null |
| initials | text | not null default ''::text |
| profile_avatar_url | text | |
| role_code | text | not null default 'member'::text |
+--------------------+--------------------------+--------------------------------------+
Indexes:
"user_account_pkey" PRIMARY KEY, btree (user_id)
"user_account_email_key" UNIQUE CONSTRAINT, btree (email)
"user_account_username_key" UNIQUE CONSTRAINT, btree (username)
Foreign-key constraints:
"user_account_role_code_fkey" FOREIGN KEY (role_code) REFERENCES role(code) ON DELETE CASCADE
Referenced by:
TABLE "refresh_token" CONSTRAINT "refresh_token_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE
TABLE "team" CONSTRAINT "team_owner_fkey" FOREIGN KEY (owner) REFERENCES user_account(user_id) ON DELETE CASCADE
TABLE "task_assigned" CONSTRAINT "task_assigned_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE
TABLE "team_member" CONSTRAINT "team_member_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE
TABLE "project_member" CONSTRAINT "project_member_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE