feature: add first time install process
This commit is contained in:
parent
90515f6aa4
commit
2cf6be082c
@ -24,7 +24,7 @@
|
|||||||
"plugin:@typescript-eslint/recommended"
|
"plugin:@typescript-eslint/recommended"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"prettier/prettier": "warning",
|
"prettier/prettier": "error",
|
||||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
@ -61,7 +61,7 @@
|
|||||||
"react-beautiful-dnd": "^13.0.0",
|
"react-beautiful-dnd": "^13.0.0",
|
||||||
"react-datepicker": "^2.14.1",
|
"react-datepicker": "^2.14.1",
|
||||||
"react-dom": "^16.12.0",
|
"react-dom": "^16.12.0",
|
||||||
"react-hook-form": "^5.2.0",
|
"react-hook-form": "^6.0.6",
|
||||||
"react-markdown": "^4.3.1",
|
"react-markdown": "^4.3.1",
|
||||||
"react-router": "^5.1.2",
|
"react-router": "^5.1.2",
|
||||||
"react-router-dom": "^5.1.2",
|
"react-router-dom": "^5.1.2",
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 * as H from 'history';
|
||||||
|
|
||||||
import Dashboard from 'Dashboard';
|
import Dashboard from 'Dashboard';
|
||||||
@ -8,6 +8,7 @@ import Projects from 'Projects';
|
|||||||
import Project from 'Projects/Project';
|
import Project from 'Projects/Project';
|
||||||
import Teams from 'Teams';
|
import Teams from 'Teams';
|
||||||
import Login from 'Auth';
|
import Login from 'Auth';
|
||||||
|
import Install from 'Install';
|
||||||
import Profile from 'Profile';
|
import Profile from 'Profile';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
@ -23,9 +24,10 @@ type RoutesProps = {
|
|||||||
history: H.History;
|
history: H.History;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Routes = ({ history }: RoutesProps) => (
|
const Routes = ({history}: RoutesProps) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/login" component={Login} />
|
<Route exact path="/login" component={Login} />
|
||||||
|
<Route exact path="/install" component={Install} />
|
||||||
<MainContent>
|
<MainContent>
|
||||||
<Route exact path="/" component={Dashboard} />
|
<Route exact path="/" component={Dashboard} />
|
||||||
<Route exact path="/projects" component={Projects} />
|
<Route exact path="/projects" component={Projects} />
|
||||||
|
@ -196,7 +196,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({history, name, projec
|
|||||||
<Popup title={null} tab={0}>
|
<Popup title={null} tab={0}>
|
||||||
<ProjectSettings
|
<ProjectSettings
|
||||||
onDeleteProject={() => {
|
onDeleteProject={() => {
|
||||||
setTab(1);
|
setTab(1, 300);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
@ -1,18 +1,22 @@
|
|||||||
import React, {useState, useEffect} from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import jwtDecode from 'jwt-decode';
|
import jwtDecode from 'jwt-decode';
|
||||||
import {createBrowserHistory} from 'history';
|
import { createBrowserHistory } from 'history';
|
||||||
import {setAccessToken} from 'shared/utils/accessToken';
|
import { Router } from 'react-router';
|
||||||
import styled, {ThemeProvider} from 'styled-components';
|
import { PopupProvider } from 'shared/components/PopupMenu';
|
||||||
|
import { setAccessToken } from 'shared/utils/accessToken';
|
||||||
|
import styled, { ThemeProvider } from 'styled-components';
|
||||||
import NormalizeStyles from './NormalizeStyles';
|
import NormalizeStyles from './NormalizeStyles';
|
||||||
import BaseStyles from './BaseStyles';
|
import BaseStyles from './BaseStyles';
|
||||||
import {theme} from './ThemeStyles';
|
import { theme } from './ThemeStyles';
|
||||||
import Routes from './Routes';
|
import Routes from './Routes';
|
||||||
import {UserIDContext} from './context';
|
import { UserIDContext } from './context';
|
||||||
import Navbar from './Navbar';
|
import Navbar from './Navbar';
|
||||||
import {Router} from 'react-router';
|
|
||||||
import {PopupProvider} from 'shared/components/PopupMenu';
|
|
||||||
|
|
||||||
const history = createBrowserHistory();
|
const history = createBrowserHistory();
|
||||||
|
type RefreshTokenResponse = {
|
||||||
|
accessToken: string;
|
||||||
|
isInstalled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -23,15 +27,18 @@ const App = () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}).then(async x => {
|
}).then(async x => {
|
||||||
const {status} = x;
|
const { status } = x;
|
||||||
if (status === 400) {
|
if (status === 400) {
|
||||||
history.replace('/login');
|
history.replace('/login');
|
||||||
} else {
|
} else {
|
||||||
const response: RefreshTokenResponse = await x.json();
|
const response: RefreshTokenResponse = await x.json();
|
||||||
const {accessToken} = response;
|
const { accessToken, isInstalled } = response;
|
||||||
const claims: JWTToken = jwtDecode(accessToken);
|
const claims: JWTToken = jwtDecode(accessToken);
|
||||||
setUserID(claims.userId);
|
setUserID(claims.userId);
|
||||||
setAccessToken(accessToken);
|
setAccessToken(accessToken);
|
||||||
|
if (!isInstalled) {
|
||||||
|
history.replace('/install');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
@ -39,7 +46,7 @@ const App = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UserIDContext.Provider value={{userID, setUserID}}>
|
<UserIDContext.Provider value={{ userID, setUserID }}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<NormalizeStyles />
|
<NormalizeStyles />
|
||||||
<BaseStyles />
|
<BaseStyles />
|
||||||
@ -48,10 +55,10 @@ const App = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div>loading</div>
|
<div>loading</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Routes history={history} />
|
<Routes history={history} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PopupProvider>
|
</PopupProvider>
|
||||||
</Router>
|
</Router>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
@ -1,22 +1,22 @@
|
|||||||
import React, {useState, useEffect, useContext} from 'react';
|
import React, { useState, useEffect, useContext } from 'react';
|
||||||
import {useForm} from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import {useHistory} from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
|
|
||||||
import {setAccessToken} from 'shared/utils/accessToken';
|
import { setAccessToken } from 'shared/utils/accessToken';
|
||||||
|
|
||||||
import Login from 'shared/components/Login';
|
import Login from 'shared/components/Login';
|
||||||
import {Container, LoginWrapper} from './Styles';
|
import { Container, LoginWrapper } from './Styles';
|
||||||
import UserIDContext from 'App/context';
|
import UserIDContext from 'App/context';
|
||||||
import JwtDecode from 'jwt-decode';
|
import JwtDecode from 'jwt-decode';
|
||||||
|
|
||||||
const Auth = () => {
|
const Auth = () => {
|
||||||
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
|
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const {setUserID} = useContext(UserIDContext);
|
const { setUserID } = useContext(UserIDContext);
|
||||||
const login = (
|
const login = (
|
||||||
data: LoginFormData,
|
data: LoginFormData,
|
||||||
setComplete: (val: boolean) => void,
|
setComplete: (val: boolean) => void,
|
||||||
setError: (field: string, eType: string, message: string) => void,
|
setError: (name: 'username' | 'password', error: ErrorOption) => void,
|
||||||
) => {
|
) => {
|
||||||
fetch('/auth/login', {
|
fetch('/auth/login', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@ -28,12 +28,12 @@ const Auth = () => {
|
|||||||
}).then(async x => {
|
}).then(async x => {
|
||||||
if (x.status === 401) {
|
if (x.status === 401) {
|
||||||
setInvalidLoginAttempt(invalidLoginAttempt + 1);
|
setInvalidLoginAttempt(invalidLoginAttempt + 1);
|
||||||
setError('username', 'invalid', 'Invalid username');
|
setError('username', { type: 'error', message: 'Invalid username' });
|
||||||
setError('password', 'invalid', 'Invalid password');
|
setError('password', { type: 'error', message: 'Invalid password' });
|
||||||
setComplete(true);
|
setComplete(true);
|
||||||
} else {
|
} else {
|
||||||
const response = await x.json();
|
const response = await x.json();
|
||||||
const {accessToken} = response;
|
const { accessToken } = response;
|
||||||
const claims: JWTToken = JwtDecode(accessToken);
|
const claims: JWTToken = JwtDecode(accessToken);
|
||||||
setUserID(claims.userId);
|
setUserID(claims.userId);
|
||||||
setComplete(true);
|
setComplete(true);
|
||||||
@ -49,7 +49,7 @@ const Auth = () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}).then(async x => {
|
}).then(async x => {
|
||||||
const {status} = x;
|
const { status } = x;
|
||||||
if (status === 200) {
|
if (status === 200) {
|
||||||
history.replace('/projects');
|
history.replace('/projects');
|
||||||
}
|
}
|
||||||
|
13
frontend/src/Install/Styles.ts
Normal file
13
frontend/src/Install/Styles.ts
Normal 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%;
|
||||||
|
`;
|
85
frontend/src/Install/index.tsx
Normal file
85
frontend/src/Install/index.tsx
Normal 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;
|
@ -1,12 +1,12 @@
|
|||||||
import React, {useState, useRef, useContext, useEffect} from 'react';
|
import React, { useState, useRef, useContext, useEffect } from 'react';
|
||||||
import {MENU_TYPES} from 'shared/components/TopNavbar';
|
import { MENU_TYPES } from 'shared/components/TopNavbar';
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
import updateApolloCache from 'shared/utils/cache';
|
||||||
import GlobalTopNavbar, {ProjectPopup} from 'App/TopNavbar';
|
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
||||||
import styled, {css} from 'styled-components/macro';
|
import styled, { css } from 'styled-components/macro';
|
||||||
import {Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter} from 'shared/icons';
|
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
|
||||||
import LabelManagerEditor from '../LabelManagerEditor'
|
import LabelManagerEditor from '../LabelManagerEditor';
|
||||||
import {usePopup, Popup} from 'shared/components/PopupMenu';
|
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||||
import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation} from 'react-router-dom';
|
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useSetProjectOwnerMutation,
|
useSetProjectOwnerMutation,
|
||||||
useUpdateProjectMemberRoleMutation,
|
useUpdateProjectMemberRoleMutation,
|
||||||
@ -61,7 +61,7 @@ const ProjectActions = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ProjectAction = styled.div<{disabled?: boolean}>`
|
const ProjectAction = styled.div<{ disabled?: boolean }>`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -103,18 +103,21 @@ const initialQuickCardEditorState: QuickCardEditorState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ProjectBoardProps = {
|
type ProjectBoardProps = {
|
||||||
|
onCardLabelClick: () => void;
|
||||||
|
cardLabelVariant: CardLabelVariant;
|
||||||
projectID: string;
|
projectID: string;
|
||||||
};
|
};
|
||||||
const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|
||||||
|
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
|
||||||
const [assignTask] = useAssignTaskMutation();
|
const [assignTask] = useAssignTaskMutation();
|
||||||
const [unassignTask] = useUnassignTaskMutation();
|
const [unassignTask] = useUnassignTaskMutation();
|
||||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
const $labelsRef = useRef<HTMLDivElement>(null);
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||||
const {showPopup, hidePopup} = usePopup();
|
const { showPopup, hidePopup } = usePopup();
|
||||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||||
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
|
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
|
||||||
const {userID} = useContext(UserIDContext);
|
const { userID } = useContext(UserIDContext);
|
||||||
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
|
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
|
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
|
||||||
@ -128,7 +131,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
|
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
{projectId: projectID},
|
{ projectId: projectID },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -140,13 +143,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
FindProjectDocument,
|
FindProjectDocument,
|
||||||
cache =>
|
cache =>
|
||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
const {taskGroups} = cache.findProject;
|
const { taskGroups } = cache.findProject;
|
||||||
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
|
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
|
||||||
if (idx !== -1) {
|
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>(
|
updateApolloCache<FindProjectQuery>(
|
||||||
client,
|
client,
|
||||||
FindProjectDocument,
|
FindProjectDocument,
|
||||||
cache => produce(cache, draftCache => {
|
cache =>
|
||||||
draftCache.findProject.taskGroups.push({...newTaskGroupData.data.createTaskGroup, tasks: []});
|
produce(cache, draftCache => {
|
||||||
}),
|
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
|
||||||
{projectId: projectID},
|
}),
|
||||||
|
{ projectId: projectID },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
|
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
|
||||||
const {loading, data} = useFindProjectQuery({
|
const { loading, data } = useFindProjectQuery({
|
||||||
variables: {projectId: projectID},
|
variables: { projectId: projectID },
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
|
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
|
||||||
@ -178,9 +182,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
FindProjectDocument,
|
FindProjectDocument,
|
||||||
cache =>
|
cache =>
|
||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
const {previousTaskGroupID, task} = newTask.data.updateTaskLocation;
|
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
|
||||||
if (previousTaskGroupID !== task.taskGroup.id) {
|
if (previousTaskGroupID !== task.taskGroup.id) {
|
||||||
const {taskGroups} = cache.findProject;
|
const { taskGroups } = cache.findProject;
|
||||||
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
|
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
|
||||||
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
|
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
|
||||||
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
|
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
|
||||||
@ -189,12 +193,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
);
|
);
|
||||||
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
|
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
|
||||||
...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}`);
|
console.log(`position ${position}`);
|
||||||
createTask({
|
createTask({
|
||||||
variables: {taskGroupID, name, position},
|
variables: { taskGroupID, name, position },
|
||||||
optimisticResponse: {
|
optimisticResponse: {
|
||||||
__typename: 'Mutation',
|
__typename: 'Mutation',
|
||||||
createTask: {
|
createTask: {
|
||||||
@ -258,7 +262,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
if (lastColumn) {
|
if (lastColumn) {
|
||||||
position = lastColumn.position * 2 + 1;
|
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 => {
|
onTaskClick={task => {
|
||||||
history.push(`${match.url}/c/${task.id}`);
|
history.push(`${match.url}/c/${task.id}`);
|
||||||
}}
|
}}
|
||||||
|
onCardLabelClick={onCardLabelClick}
|
||||||
|
cardLabelVariant={cardLabelVariant}
|
||||||
onTaskDrop={(droppedTask, previousTaskGroupID) => {
|
onTaskDrop={(droppedTask, previousTaskGroupID) => {
|
||||||
updateTaskLocation({
|
updateTaskLocation({
|
||||||
variables: {
|
variables: {
|
||||||
@ -370,7 +376,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
}}
|
}}
|
||||||
onTaskGroupDrop={droppedTaskGroup => {
|
onTaskGroupDrop={droppedTaskGroup => {
|
||||||
updateTaskGroupLocation({
|
updateTaskGroupLocation({
|
||||||
variables: {taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position},
|
variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position },
|
||||||
optimisticResponse: {
|
optimisticResponse: {
|
||||||
__typename: 'Mutation',
|
__typename: 'Mutation',
|
||||||
updateTaskGroupLocation: {
|
updateTaskGroupLocation: {
|
||||||
@ -400,7 +406,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChangeTaskGroupName={(taskGroupID, name) => {
|
onChangeTaskGroupName={(taskGroupID, name) => {
|
||||||
updateTaskGroupName({variables: {taskGroupID, name}});
|
updateTaskGroupName({ variables: { taskGroupID, name } });
|
||||||
}}
|
}}
|
||||||
onQuickEditorOpen={onQuickEditorOpen}
|
onQuickEditorOpen={onQuickEditorOpen}
|
||||||
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
|
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
|
||||||
@ -410,7 +416,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
<ListActions
|
<ListActions
|
||||||
taskGroupID={taskGroupID}
|
taskGroupID={taskGroupID}
|
||||||
onArchiveTaskGroup={tgID => {
|
onArchiveTaskGroup={tgID => {
|
||||||
deleteTaskGroup({variables: {taskGroupID: tgID}});
|
deleteTaskGroup({ variables: { taskGroupID: tgID } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -423,7 +429,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
task={currentQuickTask}
|
task={currentQuickTask}
|
||||||
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
|
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
|
||||||
onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => {
|
onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => {
|
||||||
updateTaskName({variables: {taskID, name: cardName}});
|
updateTaskName({ variables: { taskID, name: cardName } });
|
||||||
}}
|
}}
|
||||||
onOpenMembersPopup={($targetRef, task) => {
|
onOpenMembersPopup={($targetRef, task) => {
|
||||||
showPopup(
|
showPopup(
|
||||||
@ -434,9 +440,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
activeMembers={task.assigned ?? []}
|
activeMembers={task.assigned ?? []}
|
||||||
onMemberChange={(member, isActive) => {
|
onMemberChange={(member, isActive) => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
assignTask({variables: {taskID: task.id, userID: userID ?? ''}});
|
assignTask({ variables: { taskID: task.id, userID: userID ?? '' } });
|
||||||
} else {
|
} 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,
|
$targetRef,
|
||||||
<LabelManagerEditor
|
<LabelManagerEditor
|
||||||
onLabelToggle={labelID => {
|
onLabelToggle={labelID => {
|
||||||
toggleTaskLabel({variables: {taskID: task.id, projectLabelID: labelID}});
|
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||||
}}
|
}}
|
||||||
labelColors={data.labelColors}
|
labelColors={data.labelColors}
|
||||||
labels={labelsRef}
|
labels={labelsRef}
|
||||||
@ -475,7 +481,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
}}
|
}}
|
||||||
onArchiveCard={(_listId: string, cardId: string) =>
|
onArchiveCard={(_listId: string, cardId: string) =>
|
||||||
deleteTask({
|
deleteTask({
|
||||||
variables: {taskID: cardId},
|
variables: { taskID: cardId },
|
||||||
update: client => {
|
update: client => {
|
||||||
updateApolloCache<FindProjectQuery>(
|
updateApolloCache<FindProjectQuery>(
|
||||||
client,
|
client,
|
||||||
@ -487,7 +493,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
tasks: taskGroup.tasks.filter(t => t.id !== cardId),
|
tasks: taskGroup.tasks.filter(t => t.id !== cardId),
|
||||||
}));
|
}));
|
||||||
}),
|
}),
|
||||||
{projectId: projectID},
|
{ projectId: projectID },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -499,11 +505,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
<DueDateManager
|
<DueDateManager
|
||||||
task={task}
|
task={task}
|
||||||
onRemoveDueDate={t => {
|
onRemoveDueDate={t => {
|
||||||
updateTaskDueDate({variables: {taskID: t.id, dueDate: null}});
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onDueDateChange={(t, newDueDate) => {
|
onDueDateChange={(t, newDueDate) => {
|
||||||
updateTaskDueDate({variables: {taskID: t.id, dueDate: newDueDate}});
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onCancel={() => {}}
|
onCancel={() => {}}
|
||||||
@ -512,7 +518,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onToggleComplete={task => {
|
onToggleComplete={task => {
|
||||||
setTaskComplete({variables: {taskID: task.id, complete: !task.complete}});
|
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
||||||
}}
|
}}
|
||||||
target={quickCardEditor.target}
|
target={quickCardEditor.target}
|
||||||
/>
|
/>
|
||||||
|
@ -5,22 +5,22 @@ import PopupMenu, { Popup, usePopup } from 'shared/components/PopupMenu';
|
|||||||
import MemberManager from 'shared/components/MemberManager';
|
import MemberManager from 'shared/components/MemberManager';
|
||||||
import { useRouteMatch, useHistory } from 'react-router';
|
import { useRouteMatch, useHistory } from 'react-router';
|
||||||
import {
|
import {
|
||||||
useDeleteTaskChecklistMutation,
|
useDeleteTaskChecklistMutation,
|
||||||
useUpdateTaskChecklistNameMutation,
|
useUpdateTaskChecklistNameMutation,
|
||||||
useUpdateTaskChecklistItemLocationMutation,
|
useUpdateTaskChecklistItemLocationMutation,
|
||||||
useCreateTaskChecklistMutation,
|
useCreateTaskChecklistMutation,
|
||||||
useFindTaskQuery,
|
useFindTaskQuery,
|
||||||
useUpdateTaskDueDateMutation,
|
useUpdateTaskDueDateMutation,
|
||||||
useSetTaskCompleteMutation,
|
useSetTaskCompleteMutation,
|
||||||
useAssignTaskMutation,
|
useAssignTaskMutation,
|
||||||
useUnassignTaskMutation,
|
useUnassignTaskMutation,
|
||||||
useSetTaskChecklistItemCompleteMutation,
|
useSetTaskChecklistItemCompleteMutation,
|
||||||
useUpdateTaskChecklistLocationMutation,
|
useUpdateTaskChecklistLocationMutation,
|
||||||
useDeleteTaskChecklistItemMutation,
|
useDeleteTaskChecklistItemMutation,
|
||||||
useUpdateTaskChecklistItemNameMutation,
|
useUpdateTaskChecklistItemNameMutation,
|
||||||
useCreateTaskChecklistItemMutation,
|
useCreateTaskChecklistItemMutation,
|
||||||
FindTaskDocument,
|
FindTaskDocument,
|
||||||
FindTaskQuery,
|
FindTaskQuery,
|
||||||
} from 'shared/generated/graphql';
|
} from 'shared/generated/graphql';
|
||||||
import UserIDContext from 'App/context';
|
import UserIDContext from 'App/context';
|
||||||
import MiniProfile from 'shared/components/MiniProfile';
|
import MiniProfile from 'shared/components/MiniProfile';
|
||||||
@ -33,23 +33,23 @@ import { useForm } from 'react-hook-form';
|
|||||||
import updateApolloCache from 'shared/utils/cache';
|
import updateApolloCache from 'shared/utils/cache';
|
||||||
|
|
||||||
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
|
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
|
||||||
const total = checklists.reduce((prev: any, next: any) => {
|
const total = checklists.reduce((prev: any, next: any) => {
|
||||||
return (
|
return (
|
||||||
prev +
|
prev +
|
||||||
next.items.reduce((innerPrev: any, _item: any) => {
|
next.items.reduce((innerPrev: any, _item: any) => {
|
||||||
return innerPrev + 1;
|
return innerPrev + 1;
|
||||||
}, 0)
|
}, 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,
|
|
||||||
);
|
);
|
||||||
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)`
|
const DeleteChecklistButton = styled(Button)`
|
||||||
@ -58,7 +58,7 @@ const DeleteChecklistButton = styled(Button)`
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
`;
|
`;
|
||||||
type CreateChecklistData = {
|
type CreateChecklistData = {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
const CreateChecklistForm = styled.form`
|
const CreateChecklistForm = styled.form`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -80,437 +80,441 @@ const InputError = styled.span`
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
`;
|
`;
|
||||||
type CreateChecklistPopupProps = {
|
type CreateChecklistPopupProps = {
|
||||||
onCreateChecklist: (data: CreateChecklistData) => void;
|
onCreateChecklist: (data: CreateChecklistData) => void;
|
||||||
};
|
};
|
||||||
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
|
const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChecklist }) => {
|
||||||
const { register, handleSubmit, errors } = useForm<CreateChecklistData>();
|
const { register, handleSubmit, errors } = useForm<CreateChecklistData>();
|
||||||
const createUser = (data: CreateChecklistData) => {
|
const createUser = (data: CreateChecklistData) => {
|
||||||
onCreateChecklist(data);
|
onCreateChecklist(data);
|
||||||
};
|
};
|
||||||
console.log(errors);
|
console.log(errors);
|
||||||
return (
|
return (
|
||||||
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
|
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
|
||||||
<CreateChecklistInput
|
<CreateChecklistInput
|
||||||
floatingLabel
|
floatingLabel
|
||||||
value="Checklist"
|
defaultValue="Checklist"
|
||||||
width="100%"
|
width="100%"
|
||||||
label="Name"
|
label="Name"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
variant="alternate"
|
variant="alternate"
|
||||||
ref={register({ required: 'Checklist name is required' })}
|
ref={register({ required: 'Checklist name is required' })}
|
||||||
/>
|
/>
|
||||||
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
|
<CreateChecklistButton type="submit">Create</CreateChecklistButton>
|
||||||
</CreateChecklistForm>
|
</CreateChecklistForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type DetailsProps = {
|
type DetailsProps = {
|
||||||
taskID: string;
|
taskID: string;
|
||||||
projectURL: string;
|
projectURL: string;
|
||||||
onTaskNameChange: (task: Task, newName: string) => void;
|
onTaskNameChange: (task: Task, newName: string) => void;
|
||||||
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
|
onTaskDescriptionChange: (task: Task, newDescription: string) => void;
|
||||||
onDeleteTask: (task: Task) => void;
|
onDeleteTask: (task: Task) => void;
|
||||||
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
availableMembers: Array<TaskUser>;
|
availableMembers: Array<TaskUser>;
|
||||||
refreshCache: () => void;
|
refreshCache: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
|
const initialMemberPopupState = { taskID: '', isOpen: false, top: 0, left: 0 };
|
||||||
|
|
||||||
const Details: React.FC<DetailsProps> = ({
|
const Details: React.FC<DetailsProps> = ({
|
||||||
projectURL,
|
projectURL,
|
||||||
taskID,
|
taskID,
|
||||||
onTaskNameChange,
|
onTaskNameChange,
|
||||||
onTaskDescriptionChange,
|
onTaskDescriptionChange,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
onOpenAddLabelPopup,
|
onOpenAddLabelPopup,
|
||||||
availableMembers,
|
availableMembers,
|
||||||
refreshCache,
|
refreshCache,
|
||||||
}) => {
|
}) => {
|
||||||
const { userID } = useContext(UserIDContext);
|
const { userID } = useContext(UserIDContext);
|
||||||
const { showPopup, hidePopup } = usePopup();
|
const { showPopup, hidePopup } = usePopup();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const [currentMemberTask, setCurrentMemberTask] = useState('');
|
const [currentMemberTask, setCurrentMemberTask] = useState('');
|
||||||
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
|
const [memberPopupData, setMemberPopupData] = useState(initialMemberPopupState);
|
||||||
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
|
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
|
||||||
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
|
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
|
||||||
update: (client, response) => {
|
update: (client, response) => {
|
||||||
updateApolloCache<FindTaskQuery>(
|
updateApolloCache<FindTaskQuery>(
|
||||||
client,
|
client,
|
||||||
FindTaskDocument,
|
FindTaskDocument,
|
||||||
cache =>
|
cache =>
|
||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
|
const { prevChecklistID, checklistID, checklistItem } = response.data.updateTaskChecklistItemLocation;
|
||||||
console.log(`${checklistID} !== ${prevChecklistID}`);
|
console.log(`${checklistID} !== ${prevChecklistID}`);
|
||||||
if (checklistID !== prevChecklistID) {
|
if (checklistID !== prevChecklistID) {
|
||||||
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
|
const oldIdx = cache.findTask.checklists.findIndex(c => c.id === prevChecklistID);
|
||||||
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
|
const newIdx = cache.findTask.checklists.findIndex(c => c.id === checklistID);
|
||||||
console.log(`oldIdx ${oldIdx} newIdx ${newIdx}`);
|
console.log(`oldIdx ${oldIdx} newIdx ${newIdx}`);
|
||||||
if (oldIdx > -1 && newIdx > -1) {
|
if (oldIdx > -1 && newIdx > -1) {
|
||||||
const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id);
|
const item = cache.findTask.checklists[oldIdx].items.find(item => item.id === checklistItem.id);
|
||||||
console.log(item);
|
console.log(item);
|
||||||
if (item) {
|
if (item) {
|
||||||
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
|
draftCache.findTask.checklists[oldIdx].items = cache.findTask.checklists[oldIdx].items.filter(
|
||||||
i => i.id !== checklistItem.id,
|
i => i.id !== checklistItem.id,
|
||||||
);
|
);
|
||||||
draftCache.findTask.checklists[newIdx].items.push({
|
draftCache.findTask.checklists[newIdx].items.push({
|
||||||
...item,
|
...item,
|
||||||
position: checklistItem.position,
|
position: checklistItem.position,
|
||||||
taskChecklistID: checklistID,
|
taskChecklistID: checklistID,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{ taskID },
|
{ 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 { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||||
});
|
draftCache.findTask.badges.checklist = {
|
||||||
const [setTaskChecklistItemComplete] = useSetTaskChecklistItemCompleteMutation({
|
__typename: 'ChecklistBadge',
|
||||||
update: client => {
|
complete,
|
||||||
updateApolloCache<FindTaskQuery>(
|
total,
|
||||||
client,
|
};
|
||||||
FindTaskDocument,
|
if (complete === 0 && total === 0) {
|
||||||
cache =>
|
draftCache.findTask.badges.checklist = null;
|
||||||
produce(cache, draftCache => {
|
}
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
}),
|
||||||
draftCache.findTask.badges.checklist = {
|
{ taskID },
|
||||||
__typename: 'ChecklistBadge',
|
);
|
||||||
complete,
|
},
|
||||||
total,
|
});
|
||||||
};
|
const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation();
|
||||||
}),
|
const [createTaskChecklist] = useCreateTaskChecklistMutation({
|
||||||
{ taskID },
|
update: (client, createData) => {
|
||||||
);
|
updateApolloCache<FindTaskQuery>(
|
||||||
},
|
client,
|
||||||
});
|
FindTaskDocument,
|
||||||
const [deleteTaskChecklist] = useDeleteTaskChecklistMutation({
|
cache =>
|
||||||
update: (client, deleteData) => {
|
produce(cache, draftCache => {
|
||||||
updateApolloCache<FindTaskQuery>(
|
const item = createData.data.createTaskChecklist;
|
||||||
client,
|
draftCache.findTask.checklists.push({ ...item });
|
||||||
FindTaskDocument,
|
}),
|
||||||
cache =>
|
{ taskID },
|
||||||
produce(cache, draftCache => {
|
);
|
||||||
const { checklists } = cache.findTask;
|
},
|
||||||
console.log(deleteData)
|
});
|
||||||
draftCache.findTask.checklists = checklists.filter(c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id);
|
const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation();
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({
|
||||||
draftCache.findTask.badges.checklist = {
|
update: (client, deleteData) => {
|
||||||
__typename: 'ChecklistBadge',
|
updateApolloCache<FindTaskQuery>(
|
||||||
complete,
|
client,
|
||||||
total,
|
FindTaskDocument,
|
||||||
};
|
cache =>
|
||||||
if (complete === 0 && total === 0) {
|
produce(cache, draftCache => {
|
||||||
draftCache.findTask.badges.checklist = null;
|
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
||||||
}
|
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
|
||||||
}),
|
if (targetIdx > -1) {
|
||||||
{ taskID },
|
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
|
||||||
);
|
c => item.id !== c.id,
|
||||||
},
|
);
|
||||||
});
|
}
|
||||||
const [updateTaskChecklistItemName] = useUpdateTaskChecklistItemNameMutation();
|
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||||
const [createTaskChecklist] = useCreateTaskChecklistMutation({
|
draftCache.findTask.badges.checklist = {
|
||||||
update: (client, createData) => {
|
__typename: 'ChecklistBadge',
|
||||||
updateApolloCache<FindTaskQuery>(
|
complete,
|
||||||
client,
|
total,
|
||||||
FindTaskDocument,
|
};
|
||||||
cache =>
|
}),
|
||||||
produce(cache, draftCache => {
|
{ taskID },
|
||||||
const item = createData.data.createTaskChecklist;
|
);
|
||||||
draftCache.findTask.checklists.push({ ...item });
|
},
|
||||||
}),
|
});
|
||||||
{ taskID },
|
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
|
||||||
);
|
update: (client, newTaskItem) => {
|
||||||
},
|
updateApolloCache<FindTaskQuery>(
|
||||||
});
|
client,
|
||||||
const [updateTaskChecklistName] = useUpdateTaskChecklistNameMutation();
|
FindTaskDocument,
|
||||||
const [deleteTaskChecklistItem] = useDeleteTaskChecklistItemMutation({
|
cache =>
|
||||||
update: (client, deleteData) => {
|
produce(cache, draftCache => {
|
||||||
updateApolloCache<FindTaskQuery>(
|
const item = newTaskItem.data.createTaskChecklistItem;
|
||||||
client,
|
const { checklists } = cache.findTask;
|
||||||
FindTaskDocument,
|
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
|
||||||
cache =>
|
if (idx !== -1) {
|
||||||
produce(cache, draftCache => {
|
draftCache.findTask.checklists[idx].items.push({ ...item });
|
||||||
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||||
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID)
|
draftCache.findTask.badges.checklist = {
|
||||||
if (targetIdx > -1) {
|
__typename: 'ChecklistBadge',
|
||||||
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(c => item.id !== c.id);
|
complete,
|
||||||
}
|
total,
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
};
|
||||||
draftCache.findTask.badges.checklist = {
|
}
|
||||||
__typename: 'ChecklistBadge',
|
}),
|
||||||
complete,
|
{ taskID },
|
||||||
total,
|
);
|
||||||
};
|
},
|
||||||
}),
|
});
|
||||||
{ taskID },
|
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
|
||||||
);
|
const [setTaskComplete] = useSetTaskCompleteMutation();
|
||||||
},
|
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
|
||||||
});
|
onCompleted: () => {
|
||||||
const [createTaskChecklistItem] = useCreateTaskChecklistItemMutation({
|
refetch();
|
||||||
update: (client, newTaskItem) => {
|
refreshCache();
|
||||||
updateApolloCache<FindTaskQuery>(
|
},
|
||||||
client,
|
});
|
||||||
FindTaskDocument,
|
const [assignTask] = useAssignTaskMutation({
|
||||||
cache =>
|
onCompleted: () => {
|
||||||
produce(cache, draftCache => {
|
refetch();
|
||||||
const item = newTaskItem.data.createTaskChecklistItem;
|
refreshCache();
|
||||||
const { checklists } = cache.findTask;
|
},
|
||||||
const idx = checklists.findIndex(c => c.id === item.taskChecklistID);
|
});
|
||||||
if (idx !== -1) {
|
const [unassignTask] = useUnassignTaskMutation({
|
||||||
draftCache.findTask.checklists[idx].items.push({ ...item });
|
onCompleted: () => {
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
refetch();
|
||||||
draftCache.findTask.badges.checklist = {
|
refreshCache();
|
||||||
__typename: 'ChecklistBadge',
|
},
|
||||||
complete,
|
});
|
||||||
total,
|
if (loading) {
|
||||||
};
|
return <div>loading</div>;
|
||||||
}
|
}
|
||||||
}),
|
if (!data) {
|
||||||
{ taskID },
|
return <div>loading</div>;
|
||||||
);
|
}
|
||||||
},
|
return (
|
||||||
});
|
<>
|
||||||
const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID } });
|
<Modal
|
||||||
const [setTaskComplete] = useSetTaskCompleteMutation();
|
width={768}
|
||||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
|
onClose={() => {
|
||||||
onCompleted: () => {
|
history.push(projectURL);
|
||||||
refetch();
|
}}
|
||||||
refreshCache();
|
renderContent={() => {
|
||||||
},
|
return (
|
||||||
});
|
<TaskDetails
|
||||||
const [assignTask] = useAssignTaskMutation({
|
task={data.findTask}
|
||||||
onCompleted: () => {
|
onChecklistDrop={checklist => {
|
||||||
refetch();
|
updateTaskChecklistLocation({
|
||||||
refreshCache();
|
variables: { checklistID: checklist.id, position: checklist.position },
|
||||||
},
|
|
||||||
});
|
|
||||||
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: {
|
optimisticResponse: {
|
||||||
__typename: 'Mutation',
|
__typename: 'Mutation',
|
||||||
updateTaskChecklistLocation: {
|
updateTaskChecklistLocation: {
|
||||||
__typename: 'UpdateTaskChecklistLocationPayload',
|
__typename: 'UpdateTaskChecklistLocationPayload',
|
||||||
checklist: {
|
checklist: {
|
||||||
__typename: 'TaskChecklist',
|
__typename: 'TaskChecklist',
|
||||||
position: checklist.position,
|
position: checklist.position,
|
||||||
id: checklist.id,
|
id: checklist.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => {
|
onChecklistItemDrop={(prevChecklistID, checklistID, checklistItem) => {
|
||||||
updateTaskChecklistItemLocation({
|
updateTaskChecklistItemLocation({
|
||||||
variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position },
|
variables: { checklistID, checklistItemID: checklistItem.id, position: checklistItem.position },
|
||||||
|
|
||||||
optimisticResponse: {
|
optimisticResponse: {
|
||||||
__typename: 'Mutation',
|
__typename: 'Mutation',
|
||||||
updateTaskChecklistItemLocation: {
|
updateTaskChecklistItemLocation: {
|
||||||
__typename: 'UpdateTaskChecklistItemLocationPayload',
|
__typename: 'UpdateTaskChecklistItemLocationPayload',
|
||||||
prevChecklistID,
|
prevChecklistID,
|
||||||
checklistID,
|
checklistID,
|
||||||
checklistItem: {
|
checklistItem: {
|
||||||
__typename: 'TaskChecklistItem',
|
__typename: 'TaskChecklistItem',
|
||||||
position: checklistItem.position,
|
position: checklistItem.position,
|
||||||
id: checklistItem.id,
|
id: checklistItem.id,
|
||||||
taskChecklistID: checklistID,
|
taskChecklistID: checklistID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onTaskNameChange={onTaskNameChange}
|
onTaskNameChange={onTaskNameChange}
|
||||||
onTaskDescriptionChange={onTaskDescriptionChange}
|
onTaskDescriptionChange={onTaskDescriptionChange}
|
||||||
onToggleTaskComplete={task => {
|
onToggleTaskComplete={task => {
|
||||||
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
||||||
}}
|
}}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
onChangeItemName={(itemID, itemName) => {
|
onChangeItemName={(itemID, itemName) => {
|
||||||
updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } });
|
updateTaskChecklistItemName({ variables: { taskChecklistItemID: itemID, name: itemName } });
|
||||||
}}
|
}}
|
||||||
onCloseModal={() => history.push(projectURL)}
|
onCloseModal={() => history.push(projectURL)}
|
||||||
onChangeChecklistName={(checklistID, newName) => {
|
onChangeChecklistName={(checklistID, newName) => {
|
||||||
updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } });
|
updateTaskChecklistName({ variables: { taskChecklistID: checklistID, name: newName } });
|
||||||
}}
|
}}
|
||||||
onDeleteItem={(checklistID, itemID) => {
|
onDeleteItem={(checklistID, itemID) => {
|
||||||
deleteTaskChecklistItem({
|
deleteTaskChecklistItem({
|
||||||
variables: { taskChecklistItemID: itemID },
|
variables: { taskChecklistItemID: itemID },
|
||||||
optimisticResponse: {
|
optimisticResponse: {
|
||||||
__typename: 'Mutation',
|
__typename: 'Mutation',
|
||||||
deleteTaskChecklistItem: {
|
deleteTaskChecklistItem: {
|
||||||
__typename: 'DeleteTaskChecklistItemPayload',
|
__typename: 'DeleteTaskChecklistItemPayload',
|
||||||
ok: true,
|
ok: true,
|
||||||
taskChecklistItem: {
|
taskChecklistItem: {
|
||||||
__typename: 'TaskChecklistItem',
|
__typename: 'TaskChecklistItem',
|
||||||
id: itemID,
|
id: itemID,
|
||||||
taskChecklistID: checklistID,
|
taskChecklistID: checklistID,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onToggleChecklistItem={(itemID, complete) => {
|
onToggleChecklistItem={(itemID, complete) => {
|
||||||
setTaskChecklistItemComplete({
|
setTaskChecklistItemComplete({
|
||||||
variables: { taskChecklistItemID: itemID, complete },
|
variables: { taskChecklistItemID: itemID, complete },
|
||||||
optimisticResponse: {
|
optimisticResponse: {
|
||||||
__typename: 'Mutation',
|
__typename: 'Mutation',
|
||||||
setTaskChecklistItemComplete: {
|
setTaskChecklistItemComplete: {
|
||||||
__typename: 'TaskChecklistItem',
|
__typename: 'TaskChecklistItem',
|
||||||
id: itemID,
|
id: itemID,
|
||||||
complete,
|
complete,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onAddItem={(taskChecklistID, name, position) => {
|
onAddItem={(taskChecklistID, name, position) => {
|
||||||
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
|
createTaskChecklistItem({ variables: { taskChecklistID, name, position } });
|
||||||
}}
|
}}
|
||||||
onMemberProfile={($targetRef, memberID) => {
|
onMemberProfile={($targetRef, memberID) => {
|
||||||
const member = data.findTask.assigned.find(m => m.id === memberID);
|
const member = data.findTask.assigned.find(m => m.id === memberID);
|
||||||
if (member) {
|
if (member) {
|
||||||
showPopup(
|
showPopup(
|
||||||
$targetRef,
|
$targetRef,
|
||||||
<Popup title={null} onClose={() => { }} tab={0}>
|
<Popup title={null} onClose={() => {}} tab={0}>
|
||||||
<MiniProfile
|
<MiniProfile
|
||||||
user={member}
|
user={member}
|
||||||
bio="None"
|
bio="None"
|
||||||
onRemoveFromTask={() => {
|
onRemoveFromTask={() => {
|
||||||
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Popup>,
|
</Popup>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onOpenAddMemberPopup={(task, $targetRef) => {
|
onOpenAddMemberPopup={(task, $targetRef) => {
|
||||||
showPopup(
|
showPopup(
|
||||||
$targetRef,
|
$targetRef,
|
||||||
<Popup title="Members" tab={0} onClose={() => { }}>
|
<Popup title="Members" tab={0} onClose={() => {}}>
|
||||||
<MemberManager
|
<MemberManager
|
||||||
availableMembers={availableMembers}
|
availableMembers={availableMembers}
|
||||||
activeMembers={data.findTask.assigned}
|
activeMembers={data.findTask.assigned}
|
||||||
onMemberChange={(member, isActive) => {
|
onMemberChange={(member, isActive) => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
assignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
assignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
||||||
} else {
|
} else {
|
||||||
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
unassignTask({ variables: { taskID: data.findTask.id, userID: userID ?? '' } });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Popup>,
|
</Popup>,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onOpenAddLabelPopup={onOpenAddLabelPopup}
|
onOpenAddLabelPopup={onOpenAddLabelPopup}
|
||||||
onOpenAddChecklistPopup={(_task, $target) => {
|
onOpenAddChecklistPopup={(_task, $target) => {
|
||||||
showPopup(
|
showPopup(
|
||||||
$target,
|
$target,
|
||||||
<Popup
|
<Popup
|
||||||
title={'Add checklist'}
|
title={'Add checklist'}
|
||||||
tab={0}
|
tab={0}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CreateChecklistPopup
|
<CreateChecklistPopup
|
||||||
onCreateChecklist={checklistData => {
|
onCreateChecklist={checklistData => {
|
||||||
let position = 65535;
|
let position = 65535;
|
||||||
console.log(data.findTask.checklists);
|
console.log(data.findTask.checklists);
|
||||||
if (data.findTask.checklists) {
|
if (data.findTask.checklists) {
|
||||||
const [lastChecklist] = data.findTask.checklists.slice(-1);
|
const [lastChecklist] = data.findTask.checklists.slice(-1);
|
||||||
console.log(`lastCheclist ${lastChecklist}`);
|
console.log(`lastCheclist ${lastChecklist}`);
|
||||||
if (lastChecklist) {
|
if (lastChecklist) {
|
||||||
position = lastChecklist.position * 2 + 1;
|
position = lastChecklist.position * 2 + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createTaskChecklist({
|
createTaskChecklist({
|
||||||
variables: {
|
variables: {
|
||||||
taskID: data.findTask.id,
|
taskID: data.findTask.id,
|
||||||
name: checklistData.name,
|
name: checklistData.name,
|
||||||
position,
|
position,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Popup>,
|
</Popup>,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onDeleteChecklist={($target, checklistID) => {
|
onDeleteChecklist={($target, checklistID) => {
|
||||||
showPopup(
|
showPopup(
|
||||||
$target,
|
$target,
|
||||||
<Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}>
|
<Popup tab={0} title="Delete checklist?" onClose={() => hidePopup()}>
|
||||||
<p>Deleting a checklist is permanent and there is no way to get it back.</p>
|
<p>Deleting a checklist is permanent and there is no way to get it back.</p>
|
||||||
<DeleteChecklistButton
|
<DeleteChecklistButton
|
||||||
color="danger"
|
color="danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
deleteTaskChecklist({ variables: { taskChecklistID: checklistID } });
|
deleteTaskChecklist({ variables: { taskChecklistID: checklistID } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete Checklist
|
Delete Checklist
|
||||||
</DeleteChecklistButton>
|
</DeleteChecklistButton>
|
||||||
</Popup>,
|
</Popup>,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onOpenDueDatePopop={(task, $targetRef) => {
|
onOpenDueDatePopop={(task, $targetRef) => {
|
||||||
showPopup(
|
showPopup(
|
||||||
$targetRef,
|
$targetRef,
|
||||||
<Popup
|
<Popup
|
||||||
title={'Change Due Date'}
|
title={'Change Due Date'}
|
||||||
tab={0}
|
tab={0}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DueDateManager
|
<DueDateManager
|
||||||
task={task}
|
task={task}
|
||||||
onRemoveDueDate={t => {
|
onRemoveDueDate={t => {
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onDueDateChange={(t, newDueDate) => {
|
onDueDateChange={(t, newDueDate) => {
|
||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onCancel={() => { }}
|
onCancel={() => {}}
|
||||||
/>
|
/>
|
||||||
</Popup>,
|
</Popup>,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
);
|
||||||
);
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Details;
|
export default Details;
|
||||||
|
@ -1,11 +1,19 @@
|
|||||||
// LOC830
|
// LOC830
|
||||||
import React, {useState, useRef, useEffect, useContext} from 'react';
|
import React, { useState, useRef, useEffect, useContext } from 'react';
|
||||||
import updateApolloCache from 'shared/utils/cache';
|
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 styled from 'styled-components/macro';
|
||||||
import {usePopup, Popup} from 'shared/components/PopupMenu';
|
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||||
import LabelManagerEditor from './LabelManagerEditor'
|
import LabelManagerEditor from './LabelManagerEditor';
|
||||||
import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation, Redirect} from 'react-router-dom';
|
import {
|
||||||
|
useParams,
|
||||||
|
Route,
|
||||||
|
useRouteMatch,
|
||||||
|
useHistory,
|
||||||
|
RouteComponentProps,
|
||||||
|
useLocation,
|
||||||
|
Redirect,
|
||||||
|
} from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useSetProjectOwnerMutation,
|
useSetProjectOwnerMutation,
|
||||||
useUpdateProjectMemberRoleMutation,
|
useUpdateProjectMemberRoleMutation,
|
||||||
@ -29,8 +37,20 @@ import produce from 'immer';
|
|||||||
import UserIDContext from 'App/context';
|
import UserIDContext from 'App/context';
|
||||||
import Input from 'shared/components/Input';
|
import Input from 'shared/components/Input';
|
||||||
import Member from 'shared/components/Member';
|
import Member from 'shared/components/Member';
|
||||||
import Board from './Board'
|
import Board from './Board';
|
||||||
import Details from './Details'
|
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)`
|
const SearchInput = styled(Input)`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -55,7 +75,7 @@ type UserManagementPopupProps = {
|
|||||||
onAddProjectMember: (userID: string) => void;
|
onAddProjectMember: (userID: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({users, projectMembers, onAddProjectMember}) => {
|
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => {
|
||||||
return (
|
return (
|
||||||
<Popup tab={0} title="Invite a user">
|
<Popup tab={0} title="Invite a user">
|
||||||
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
|
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
|
||||||
@ -99,7 +119,7 @@ const initialQuickCardEditorState: QuickCardEditorState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Project = () => {
|
const Project = () => {
|
||||||
const {projectID} = useParams<ProjectParams>();
|
const { projectID } = useParams<ProjectParams>();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
|
|
||||||
@ -110,14 +130,16 @@ const Project = () => {
|
|||||||
console.log(taskLabelsRef.current);
|
console.log(taskLabelsRef.current);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
|
||||||
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
||||||
|
|
||||||
const [deleteTask] = useDeleteTaskMutation();
|
const [deleteTask] = useDeleteTaskMutation();
|
||||||
|
|
||||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
const [updateTaskName] = useUpdateTaskNameMutation();
|
||||||
|
|
||||||
const {loading, data} = useFindProjectQuery({
|
const { loading, data } = useFindProjectQuery({
|
||||||
variables: {projectId: projectID},
|
variables: { projectId: projectID },
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updateProjectName] = useUpdateProjectNameMutation({
|
const [updateProjectName] = useUpdateProjectNameMutation({
|
||||||
@ -129,7 +151,7 @@ const Project = () => {
|
|||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
draftCache.findProject.name = newName.data.updateProjectName.name;
|
draftCache.findProject.name = newName.data.updateProjectName.name;
|
||||||
}),
|
}),
|
||||||
{projectId: projectID},
|
{ projectId: projectID },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -141,9 +163,9 @@ const Project = () => {
|
|||||||
FindProjectDocument,
|
FindProjectDocument,
|
||||||
cache =>
|
cache =>
|
||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
draftCache.findProject.members.push({...response.data.createProjectMember.member});
|
draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
|
||||||
}),
|
}),
|
||||||
{projectId: projectID},
|
{ projectId: projectID },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -159,15 +181,15 @@ const Project = () => {
|
|||||||
m => m.id !== response.data.deleteProjectMember.member.id,
|
m => m.id !== response.data.deleteProjectMember.member.id,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
{projectId: projectID},
|
{ projectId: projectID },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {userID} = useContext(UserIDContext);
|
const { userID } = useContext(UserIDContext);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const {showPopup, hidePopup} = usePopup();
|
const { showPopup, hidePopup } = usePopup();
|
||||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
const $labelsRef = useRef<HTMLDivElement>(null);
|
||||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||||
@ -192,25 +214,25 @@ const Project = () => {
|
|||||||
<>
|
<>
|
||||||
<GlobalTopNavbar
|
<GlobalTopNavbar
|
||||||
onChangeRole={(userID, roleCode) => {
|
onChangeRole={(userID, roleCode) => {
|
||||||
updateProjectMemberRole({variables: {userID, roleCode, projectID}});
|
updateProjectMemberRole({ variables: { userID, roleCode, projectID } });
|
||||||
}}
|
}}
|
||||||
onChangeProjectOwner={uid => {
|
onChangeProjectOwner={uid => {
|
||||||
setProjectOwner({variables: {ownerID: uid, projectID}});
|
setProjectOwner({ variables: { ownerID: uid, projectID } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onRemoveFromBoard={userID => {
|
onRemoveFromBoard={userID => {
|
||||||
deleteProjectMember({variables: {userID, projectID}});
|
deleteProjectMember({ variables: { userID, projectID } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onSaveProjectName={projectName => {
|
onSaveProjectName={projectName => {
|
||||||
updateProjectName({variables: {projectID, name: projectName}});
|
updateProjectName({ variables: { projectID, name: projectName } });
|
||||||
}}
|
}}
|
||||||
onInviteUser={$target => {
|
onInviteUser={$target => {
|
||||||
showPopup(
|
showPopup(
|
||||||
$target,
|
$target,
|
||||||
<UserManagementPopup
|
<UserManagementPopup
|
||||||
onAddProjectMember={userID => {
|
onAddProjectMember={userID => {
|
||||||
createProjectMember({variables: {userID, projectID}});
|
createProjectMember({ variables: { userID, projectID } });
|
||||||
}}
|
}}
|
||||||
users={data.users}
|
users={data.users}
|
||||||
projectMembers={data.findProject.members}
|
projectMembers={data.findProject.members}
|
||||||
@ -218,23 +240,24 @@ const Project = () => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />}
|
popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />}
|
||||||
menuType={[{name: 'Board', link: location.pathname}]}
|
menuType={[{ name: 'Board', link: location.pathname }]}
|
||||||
currentTab={0}
|
currentTab={0}
|
||||||
projectMembers={data.findProject.members}
|
projectMembers={data.findProject.members}
|
||||||
projectID={projectID}
|
projectID={projectID}
|
||||||
name={data.findProject.name}
|
name={data.findProject.name}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
|
||||||
path={`${match.path}`}
|
|
||||||
exact
|
|
||||||
render={() => (
|
|
||||||
<Redirect to={`${match.url}/board`} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path={`${match.path}/board`}
|
path={`${match.path}/board`}
|
||||||
render={() => (
|
render={() => (
|
||||||
<Board projectID={projectID} />
|
<Board
|
||||||
|
cardLabelVariant={value === 'small' ? 'large' : 'small'}
|
||||||
|
onCardLabelClick={() => {
|
||||||
|
const variant = value === 'small' ? 'large' : 'small';
|
||||||
|
setValue(() => variant);
|
||||||
|
}}
|
||||||
|
projectID={projectID}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
@ -246,13 +269,13 @@ const Project = () => {
|
|||||||
projectURL={`${match.url}/board`}
|
projectURL={`${match.url}/board`}
|
||||||
taskID={routeProps.match.params.taskID}
|
taskID={routeProps.match.params.taskID}
|
||||||
onTaskNameChange={(updatedTask, newName) => {
|
onTaskNameChange={(updatedTask, newName) => {
|
||||||
updateTaskName({variables: {taskID: updatedTask.id, name: newName}});
|
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
|
||||||
}}
|
}}
|
||||||
onTaskDescriptionChange={(updatedTask, newDescription) => {
|
onTaskDescriptionChange={(updatedTask, newDescription) => {
|
||||||
updateTaskDescription({variables: {taskID: updatedTask.id, description: newDescription}});
|
updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } });
|
||||||
}}
|
}}
|
||||||
onDeleteTask={deletedTask => {
|
onDeleteTask={deletedTask => {
|
||||||
deleteTask({variables: {taskID: deletedTask.id}});
|
deleteTask({ variables: { taskID: deletedTask.id } });
|
||||||
}}
|
}}
|
||||||
onOpenAddLabelPopup={(task, $targetRef) => {
|
onOpenAddLabelPopup={(task, $targetRef) => {
|
||||||
taskLabelsRef.current = task.labels;
|
taskLabelsRef.current = task.labels;
|
||||||
@ -260,7 +283,7 @@ const Project = () => {
|
|||||||
$targetRef,
|
$targetRef,
|
||||||
<LabelManagerEditor
|
<LabelManagerEditor
|
||||||
onLabelToggle={labelID => {
|
onLabelToggle={labelID => {
|
||||||
toggleTaskLabel({variables: {taskID: task.id, projectLabelID: labelID}});
|
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||||
}}
|
}}
|
||||||
labelColors={data.labelColors}
|
labelColors={data.labelColors}
|
||||||
labels={labelsRef}
|
labels={labelsRef}
|
||||||
|
30
frontend/src/citadel.d.ts
vendored
30
frontend/src/citadel.d.ts
vendored
@ -47,6 +47,7 @@ type TaskUser = {
|
|||||||
|
|
||||||
type RefreshTokenResponse = {
|
type RefreshTokenResponse = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
isInstalled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoginFormData = {
|
type LoginFormData = {
|
||||||
@ -54,16 +55,41 @@ type LoginFormData = {
|
|||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RegisterFormData = {
|
||||||
|
username: string;
|
||||||
|
fullname: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
password_confirm: string;
|
||||||
|
initials: string;
|
||||||
|
};
|
||||||
|
|
||||||
type DueDateFormData = {
|
type DueDateFormData = {
|
||||||
endDate: string;
|
endDate: string;
|
||||||
endTime: 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 = {
|
type LoginProps = {
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
data: LoginFormData,
|
data: LoginFormData,
|
||||||
setComplete: (val: boolean) => void,
|
setComplete: (val: boolean) => void,
|
||||||
setError: (field: string, eType: string, message: string) => void,
|
setError: (name: 'username' | 'password', error: ErrorOption) => void,
|
||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -85,3 +111,5 @@ type ElementBounds = {
|
|||||||
size: ElementSize;
|
size: ElementSize;
|
||||||
position: ElementPosition;
|
position: ElementPosition;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CardLabelVariant = 'large' | 'small';
|
||||||
|
@ -2,13 +2,13 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
||||||
import {ApolloProvider} from '@apollo/react-hooks';
|
import { ApolloProvider } from '@apollo/react-hooks';
|
||||||
import {ApolloClient} from 'apollo-client';
|
import { ApolloClient } from 'apollo-client';
|
||||||
import {InMemoryCache} from 'apollo-cache-inmemory';
|
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||||
import {HttpLink} from 'apollo-link-http';
|
import { HttpLink } from 'apollo-link-http';
|
||||||
import {onError} from 'apollo-link-error';
|
import { onError } from 'apollo-link-error';
|
||||||
import {ApolloLink, Observable, fromPromise} from 'apollo-link';
|
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
|
||||||
import {getAccessToken, getNewToken, setAccessToken} from 'shared/utils/accessToken';
|
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
||||||
@ -18,7 +18,7 @@ let isRefreshing = false;
|
|||||||
let pendingRequests: any = [];
|
let pendingRequests: any = [];
|
||||||
|
|
||||||
const refreshAuthLogic = (failedRequest: 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);
|
setAccessToken(tokenRefreshResponse.data.accessToken);
|
||||||
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
|
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@ -43,7 +43,7 @@ const setRefreshing = (newVal: boolean) => {
|
|||||||
isRefreshing = newVal;
|
isRefreshing = newVal;
|
||||||
};
|
};
|
||||||
|
|
||||||
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
|
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||||
if (graphQLErrors) {
|
if (graphQLErrors) {
|
||||||
for (const err of graphQLErrors) {
|
for (const err of graphQLErrors) {
|
||||||
if (err.extensions && err.extensions.code) {
|
if (err.extensions && err.extensions.code) {
|
||||||
@ -118,9 +118,9 @@ const requestLink = new ApolloLink(
|
|||||||
|
|
||||||
const client = new ApolloClient({
|
const client = new ApolloClient({
|
||||||
link: ApolloLink.from([
|
link: ApolloLink.from([
|
||||||
onError(({graphQLErrors, networkError}) => {
|
onError(({ graphQLErrors, networkError }) => {
|
||||||
if (graphQLErrors) {
|
if (graphQLErrors) {
|
||||||
graphQLErrors.forEach(({message, locations, path}) =>
|
graphQLErrors.forEach(({ message, locations, path }) =>
|
||||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
|
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -19,20 +19,20 @@ export const RoleCheckmark = styled(Checkmark)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const permissions = [
|
const permissions = [
|
||||||
{
|
{
|
||||||
code: 'owner',
|
code: 'owner',
|
||||||
name: 'Owner',
|
name: 'Owner',
|
||||||
description:
|
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.',
|
'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',
|
code: 'admin',
|
||||||
name: 'Admin',
|
name: 'Admin',
|
||||||
description:
|
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 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`
|
export const RoleName = styled.div`
|
||||||
@ -59,13 +59,13 @@ export const MiniProfileActionItem = styled.span<{ disabled?: boolean }>`
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
${props =>
|
${props =>
|
||||||
props.disabled
|
props.disabled
|
||||||
? css`
|
? css`
|
||||||
user-select: none;
|
user-select: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: rgba(${props.theme.colors.text.primary}, 0.4);
|
color: rgba(${props.theme.colors.text.primary}, 0.4);
|
||||||
`
|
`
|
||||||
: css`
|
: css`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgb(115, 103, 240);
|
background: rgb(115, 103, 240);
|
||||||
@ -104,178 +104,198 @@ export const RemoveMemberButton = styled(Button)`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
type TeamRoleManagerPopupProps = {
|
type TeamRoleManagerPopupProps = {
|
||||||
user: TaskUser;
|
user: TaskUser;
|
||||||
warning?: string | null;
|
warning?: string | null;
|
||||||
canChangeRole: boolean;
|
canChangeRole: boolean;
|
||||||
onChangeRole: (roleCode: RoleCode) => void;
|
onChangeRole: (roleCode: RoleCode) => void;
|
||||||
updateUserPassword?: (user: TaskUser, password: string) => void;
|
updateUserPassword?: (user: TaskUser, password: string) => void;
|
||||||
onRemoveFromTeam?: () => void;
|
onRemoveFromTeam?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||||
warning,
|
warning,
|
||||||
user,
|
user,
|
||||||
canChangeRole,
|
canChangeRole,
|
||||||
onRemoveFromTeam,
|
onRemoveFromTeam,
|
||||||
updateUserPassword,
|
updateUserPassword,
|
||||||
onChangeRole,
|
onChangeRole,
|
||||||
}) => {
|
}) => {
|
||||||
const { hidePopup, setTab } = usePopup();
|
const { hidePopup, setTab } = usePopup();
|
||||||
const [userPass, setUserPass] = useState({ pass: "", passConfirm: "" });
|
const [userPass, setUserPass] = useState({ pass: '', passConfirm: '' });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Popup title={null} tab={0}>
|
<Popup title={null} tab={0}>
|
||||||
<MiniProfileActions>
|
<MiniProfileActions>
|
||||||
<MiniProfileActionWrapper>
|
<MiniProfileActionWrapper>
|
||||||
{user.role && (
|
{user.role && (
|
||||||
<MiniProfileActionItem
|
<MiniProfileActionItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTab(1);
|
setTab(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Change permissions...
|
Change permissions...
|
||||||
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
||||||
</MiniProfileActionItem>
|
</MiniProfileActionItem>
|
||||||
)}
|
)}
|
||||||
<MiniProfileActionItem onClick={() => {
|
<MiniProfileActionItem
|
||||||
setTab(3)
|
onClick={() => {
|
||||||
}}>Reset password...</MiniProfileActionItem>
|
setTab(3);
|
||||||
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
|
}}
|
||||||
</MiniProfileActionWrapper>
|
>
|
||||||
</MiniProfileActions>
|
Reset password...
|
||||||
{warning && (
|
</MiniProfileActionItem>
|
||||||
<>
|
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
|
||||||
<Separator />
|
</MiniProfileActionWrapper>
|
||||||
<WarningText>{warning}</WarningText>
|
</MiniProfileActions>
|
||||||
</>
|
{warning && (
|
||||||
)}
|
<>
|
||||||
</Popup>
|
<Separator />
|
||||||
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
|
<WarningText>{warning}</WarningText>
|
||||||
<MiniProfileActions>
|
</>
|
||||||
<MiniProfileActionWrapper>
|
)}
|
||||||
{permissions
|
</Popup>
|
||||||
.filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner')
|
<Popup title="Change Permissions" onClose={() => hidePopup()} tab={1}>
|
||||||
.map(perm => (
|
<MiniProfileActions>
|
||||||
<MiniProfileActionItem
|
<MiniProfileActionWrapper>
|
||||||
disabled={user.role && perm.code !== user.role.code && !canChangeRole}
|
{permissions
|
||||||
key={perm.code}
|
.filter(p => (user.role && user.role.code === 'owner') || p.code !== 'owner')
|
||||||
onClick={() => {
|
.map(perm => (
|
||||||
if (onChangeRole && user.role && perm.code !== user.role.code) {
|
<MiniProfileActionItem
|
||||||
switch (perm.code) {
|
disabled={user.role && perm.code !== user.role.code && !canChangeRole}
|
||||||
case 'owner':
|
key={perm.code}
|
||||||
onChangeRole(RoleCode.Owner);
|
onClick={() => {
|
||||||
break;
|
if (onChangeRole && user.role && perm.code !== user.role.code) {
|
||||||
case 'admin':
|
switch (perm.code) {
|
||||||
onChangeRole(RoleCode.Admin);
|
case 'owner':
|
||||||
break;
|
onChangeRole(RoleCode.Owner);
|
||||||
case 'member':
|
break;
|
||||||
onChangeRole(RoleCode.Member);
|
case 'admin':
|
||||||
break;
|
onChangeRole(RoleCode.Admin);
|
||||||
default:
|
break;
|
||||||
break;
|
case 'member':
|
||||||
}
|
onChangeRole(RoleCode.Member);
|
||||||
hidePopup();
|
break;
|
||||||
}
|
default:
|
||||||
}}
|
break;
|
||||||
>
|
}
|
||||||
<RoleName>
|
hidePopup();
|
||||||
{perm.name}
|
}
|
||||||
{user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />}
|
}}
|
||||||
</RoleName>
|
>
|
||||||
<RoleDescription>{perm.description}</RoleDescription>
|
<RoleName>
|
||||||
</MiniProfileActionItem>
|
{perm.name}
|
||||||
))}
|
{user.role && perm.code === user.role.code && <RoleCheckmark width={12} height={12} />}
|
||||||
</MiniProfileActionWrapper>
|
</RoleName>
|
||||||
{user.role && user.role.code === 'owner' && (
|
<RoleDescription>{perm.description}</RoleDescription>
|
||||||
<>
|
</MiniProfileActionItem>
|
||||||
<Separator />
|
))}
|
||||||
<WarningText>You can't change roles because there must be an owner.</WarningText>
|
</MiniProfileActionWrapper>
|
||||||
</>
|
{user.role && user.role.code === 'owner' && (
|
||||||
)}
|
<>
|
||||||
</MiniProfileActions>
|
<Separator />
|
||||||
</Popup>
|
<WarningText>You can't change roles because there must be an owner.</WarningText>
|
||||||
<Popup title="Remove from Team?" onClose={() => hidePopup()} tab={2}>
|
</>
|
||||||
<Content>
|
)}
|
||||||
<DeleteDescription>
|
</MiniProfileActions>
|
||||||
The member will be removed from all cards on this project. They will receive a notification.
|
</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>
|
</DeleteDescription>
|
||||||
<RemoveMemberButton
|
<RemoveMemberButton
|
||||||
color="danger"
|
color="danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onRemoveFromTeam) {
|
if (onRemoveFromTeam) {
|
||||||
onRemoveFromTeam();
|
onRemoveFromTeam();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove Member
|
Remove Member
|
||||||
</RemoveMemberButton>
|
</RemoveMemberButton>
|
||||||
</Content>
|
</Content>
|
||||||
</Popup>
|
</Popup>
|
||||||
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
|
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
|
||||||
<Content>
|
<Content>
|
||||||
<DeleteDescription>
|
<DeleteDescription>
|
||||||
You can either set the user's new password directly or send the user an email allowing them to reset their own password.
|
You can either set the user's new password directly or send the user an email allowing them to reset their
|
||||||
|
own password.
|
||||||
</DeleteDescription>
|
</DeleteDescription>
|
||||||
<UserPassBar>
|
<UserPassBar>
|
||||||
<UserPassButton onClick={() => setTab(4)} color="warning">Set password...</UserPassButton>
|
<UserPassButton onClick={() => setTab(4)} color="warning">
|
||||||
<UserPassButton color="warning" variant="outline">Send reset link</UserPassButton>
|
Set password...
|
||||||
</UserPassBar>
|
</UserPassButton>
|
||||||
</Content>
|
<UserPassButton color="warning" variant="outline">
|
||||||
</Popup>
|
Send reset link
|
||||||
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
</UserPassButton>
|
||||||
<Content>
|
</UserPassBar>
|
||||||
<NewUserPassInput onChange={e => setUserPass({ pass: e.currentTarget.value, passConfirm: userPass.passConfirm })} value={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
</Content>
|
||||||
<NewUserPassInput onChange={e => setUserPass({ passConfirm: e.currentTarget.value, pass: userPass.pass })} value={userPass.passConfirm} width="100%" variant="alternate" placeholder="New password (confirm)" />
|
</Popup>
|
||||||
<UserPassConfirmButton disabled={userPass.pass === "" || userPass.passConfirm === ""} onClick={() => {
|
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
||||||
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
|
<Content>
|
||||||
updateUserPassword(user, userPass.pass)
|
<NewUserPassInput defaultValue={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
||||||
}
|
<NewUserPassInput
|
||||||
}} color="danger">Set password</UserPassConfirmButton>
|
defaultValue={userPass.passConfirm}
|
||||||
</Content>
|
width="100%"
|
||||||
</Popup>
|
variant="alternate"
|
||||||
<Popup title="Remove user" onClose={() => hidePopup()} tab={5}>
|
placeholder="New password (confirm)"
|
||||||
<Content>
|
/>
|
||||||
<DeleteDescription>
|
<UserPassConfirmButton
|
||||||
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
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>
|
||||||
<DeleteDescription>
|
<DeleteDescription>The user is the owner of 3 projects & 2 teams.</DeleteDescription>
|
||||||
The user is the owner of 3 projects & 2 teams.
|
<UserSelect onChange={() => {}} value={null} options={[{ label: 'Jordan Knott', value: 'jordanknott' }]} />
|
||||||
</DeleteDescription>
|
<UserPassConfirmButton onClick={() => {}} color="danger">
|
||||||
<UserSelect onChange={() => { }} value={null} options={[{ label: 'Jordan Knott', value: "jordanknott" }]} />
|
Set password
|
||||||
<UserPassConfirmButton onClick={() => { }} color="danger">Set password</UserPassConfirmButton>
|
</UserPassConfirmButton>
|
||||||
</Content>
|
</Content>
|
||||||
</Popup>
|
</Popup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const UserSelect = styled(Select)`
|
const UserSelect = styled(Select)`
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
`
|
`;
|
||||||
|
|
||||||
const NewUserPassInput = styled(Input)`
|
const NewUserPassInput = styled(Input)`
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
`
|
`;
|
||||||
const InviteMemberButton = styled(Button)`
|
const InviteMemberButton = styled(Button)`
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const UserPassBar = styled.div`
|
const UserPassBar = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
`
|
`;
|
||||||
const UserPassConfirmButton = styled(Button)`
|
const UserPassConfirmButton = styled(Button)`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
`
|
`;
|
||||||
|
|
||||||
const UserPassButton = styled(Button)`
|
const UserPassButton = styled(Button)`
|
||||||
width: 50%;
|
width: 50%;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
& ~ & {
|
& ~ & {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
const MemberItemOptions = styled.div``;
|
const MemberItemOptions = styled.div``;
|
||||||
|
|
||||||
@ -413,7 +433,7 @@ const LockUserIcon = styled(Lock)``;
|
|||||||
const DeleteUserIcon = styled(Trash)``;
|
const DeleteUserIcon = styled(Trash)``;
|
||||||
|
|
||||||
type ActionButtonProps = {
|
type ActionButtonProps = {
|
||||||
onClick: ($target: React.RefObject<HTMLElement>) => void;
|
onClick: ($target: React.RefObject<HTMLElement>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActionButtonWrapper = styled.div`
|
const ActionButtonWrapper = styled.div`
|
||||||
@ -423,84 +443,84 @@ const ActionButtonWrapper = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
|
const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
|
||||||
const $wrapper = useRef<HTMLDivElement>(null);
|
const $wrapper = useRef<HTMLDivElement>(null);
|
||||||
return (
|
return (
|
||||||
<ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}>
|
<ActionButtonWrapper onClick={() => onClick($wrapper)} ref={$wrapper}>
|
||||||
{children}
|
{children}
|
||||||
</ActionButtonWrapper>
|
</ActionButtonWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActionButtons = (params: any) => {
|
const ActionButtons = (params: any) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ActionButton onClick={() => { }}>
|
<ActionButton onClick={() => {}}>
|
||||||
<EditUserIcon width={16} height={16} />
|
<EditUserIcon width={16} height={16} />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
|
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
|
||||||
<DeleteUserIcon width={16} height={16} />
|
<DeleteUserIcon width={16} height={16} />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type ListTableProps = {
|
type ListTableProps = {
|
||||||
users: Array<User>;
|
users: Array<User>;
|
||||||
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
|
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListTable: React.FC<ListTableProps> = ({ users, onDeleteUser }) => {
|
const ListTable: React.FC<ListTableProps> = ({ users, onDeleteUser }) => {
|
||||||
const data = {
|
const data = {
|
||||||
defaultColDef: {
|
defaultColDef: {
|
||||||
resizable: true,
|
resizable: true,
|
||||||
sortable: 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,
|
frameworkComponents: {
|
||||||
width: 55,
|
actionButtons: ActionButtons,
|
||||||
headerCheckboxSelection: true,
|
},
|
||||||
checkboxSelection: true,
|
};
|
||||||
},
|
return (
|
||||||
{ minWidth: 210, headerName: 'Username', editable: true, field: 'username' },
|
<Root>
|
||||||
{ minWidth: 225, headerName: 'Email', field: 'email' },
|
<div className="ag-theme-material" style={{ height: '296px', width: '100%' }}>
|
||||||
{ minWidth: 200, headerName: 'Name', editable: true, field: 'fullName' },
|
<AgGridReact
|
||||||
{ minWidth: 200, headerName: 'Role', editable: true, field: 'roleName' },
|
rowSelection="multiple"
|
||||||
{
|
defaultColDef={data.defaultColDef}
|
||||||
minWidth: 200,
|
columnDefs={data.columnDefs}
|
||||||
headerName: 'Actions',
|
rowData={users.map(u => ({ ...u, roleName: u.role.name }))}
|
||||||
field: 'id',
|
frameworkComponents={data.frameworkComponents}
|
||||||
cellRenderer: 'actionButtons',
|
onFirstDataRendered={params => {
|
||||||
cellRendererParams: {
|
params.api.sizeColumnsToFit();
|
||||||
onDeleteUser: (target: any, userID: any) => {
|
}}
|
||||||
onDeleteUser(target, userID);
|
onGridSizeChanged={params => {
|
||||||
},
|
params.api.sizeColumnsToFit();
|
||||||
},
|
}}
|
||||||
},
|
/>
|
||||||
],
|
</div>
|
||||||
frameworkComponents: {
|
</Root>
|
||||||
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`
|
const Wrapper = styled.div`
|
||||||
@ -604,138 +624,145 @@ const TabContent = styled.div`
|
|||||||
const items = [{ name: 'Members' }, { name: 'Settings' }];
|
const items = [{ name: 'Members' }, { name: 'Settings' }];
|
||||||
|
|
||||||
type NavItemProps = {
|
type NavItemProps = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
tab: number;
|
tab: number;
|
||||||
onClick: (tab: number, top: number) => void;
|
onClick: (tab: number, top: number) => void;
|
||||||
};
|
};
|
||||||
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
||||||
const $item = useRef<HTMLLIElement>(null);
|
const $item = useRef<HTMLLIElement>(null);
|
||||||
return (
|
return (
|
||||||
<TabNavItem
|
<TabNavItem
|
||||||
key={name}
|
key={name}
|
||||||
ref={$item}
|
ref={$item}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if ($item && $item.current) {
|
if ($item && $item.current) {
|
||||||
const pos = $item.current.getBoundingClientRect();
|
const pos = $item.current.getBoundingClientRect();
|
||||||
onClick(tab, pos.top);
|
onClick(tab, pos.top);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabNavItemButton active={active}>
|
<TabNavItemButton active={active}>
|
||||||
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
||||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
<TabNavItemSpan>{name}</TabNavItemSpan>
|
||||||
</TabNavItemButton>
|
</TabNavItemButton>
|
||||||
</TabNavItem>
|
</TabNavItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type AdminProps = {
|
type AdminProps = {
|
||||||
initialTab: number;
|
initialTab: number;
|
||||||
onAddUser: ($target: React.RefObject<HTMLElement>) => void;
|
onAddUser: ($target: React.RefObject<HTMLElement>) => void;
|
||||||
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
|
onDeleteUser: ($target: React.RefObject<HTMLElement>, userID: string) => void;
|
||||||
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
|
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
|
||||||
users: Array<User>;
|
users: Array<User>;
|
||||||
onUpdateUserPassword: (user: TaskUser, password: string) => void;
|
onUpdateUserPassword: (user: TaskUser, password: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPassword, onDeleteUser, onInviteUser, users }) => {
|
const Admin: React.FC<AdminProps> = ({
|
||||||
const warning =
|
initialTab,
|
||||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
onAddUser,
|
||||||
const [currentTop, setTop] = useState(initialTab * 48);
|
onUpdateUserPassword,
|
||||||
const [currentTab, setTab] = useState(initialTab);
|
onDeleteUser,
|
||||||
const { showPopup, hidePopup } = usePopup();
|
onInviteUser,
|
||||||
const $tabNav = useRef<HTMLDivElement>(null);
|
users,
|
||||||
|
}) => {
|
||||||
|
const warning =
|
||||||
|
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
||||||
|
const [currentTop, setTop] = useState(initialTab * 48);
|
||||||
|
const [currentTab, setTab] = useState(initialTab);
|
||||||
|
const { showPopup, hidePopup } = usePopup();
|
||||||
|
const $tabNav = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [updateUserRole] = useUpdateUserRoleMutation()
|
const [updateUserRole] = useUpdateUserRoleMutation();
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<TabNav ref={$tabNav}>
|
<TabNav ref={$tabNav}>
|
||||||
<TabNavContent>
|
<TabNavContent>
|
||||||
{items.map((item, idx) => (
|
{items.map((item, idx) => (
|
||||||
<NavItem
|
<NavItem
|
||||||
onClick={(tab, top) => {
|
onClick={(tab, top) => {
|
||||||
if ($tabNav && $tabNav.current) {
|
if ($tabNav && $tabNav.current) {
|
||||||
const pos = $tabNav.current.getBoundingClientRect();
|
const pos = $tabNav.current.getBoundingClientRect();
|
||||||
setTab(tab);
|
setTab(tab);
|
||||||
setTop(top - pos.top);
|
setTop(top - pos.top);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
name={item.name}
|
name={item.name}
|
||||||
tab={idx}
|
tab={idx}
|
||||||
active={idx === currentTab}
|
active={idx === currentTab}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<TabNavLine top={currentTop} />
|
<TabNavLine top={currentTop} />
|
||||||
</TabNavContent>
|
</TabNavContent>
|
||||||
</TabNav>
|
</TabNav>
|
||||||
<TabContentWrapper>
|
<TabContentWrapper>
|
||||||
<TabContent>
|
<TabContent>
|
||||||
<MemberListWrapper>
|
<MemberListWrapper>
|
||||||
<MemberListHeader>
|
<MemberListHeader>
|
||||||
<ListTitle>{`Users (${users.length})`}</ListTitle>
|
<ListTitle>{`Users (${users.length})`}</ListTitle>
|
||||||
<ListDesc>
|
<ListDesc>
|
||||||
Team members can view and join all Team Visible boards and create new boards in the team.
|
Team members can view and join all Team Visible boards and create new boards in the team.
|
||||||
</ListDesc>
|
</ListDesc>
|
||||||
<ListActions>
|
<ListActions>
|
||||||
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
|
<FilterSearch width="250px" variant="alternate" placeholder="Filter by name" />
|
||||||
<InviteMemberButton
|
<InviteMemberButton
|
||||||
onClick={$target => {
|
onClick={$target => {
|
||||||
onAddUser($target);
|
onAddUser($target);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InviteIcon width={16} height={16} />
|
<InviteIcon width={16} height={16} />
|
||||||
New Member
|
New Member
|
||||||
</InviteMemberButton>
|
</InviteMemberButton>
|
||||||
</ListActions>
|
</ListActions>
|
||||||
</MemberListHeader>
|
</MemberListHeader>
|
||||||
<MemberList>
|
<MemberList>
|
||||||
{users.map(member => (
|
{users.map(member => (
|
||||||
<MemberListItem>
|
<MemberListItem>
|
||||||
<MemberProfile showRoleIcons size={32} onMemberProfile={() => { }} member={member} />
|
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
|
||||||
<MemberListItemDetails>
|
<MemberListItemDetails>
|
||||||
<MemberItemName>{member.fullName}</MemberItemName>
|
<MemberItemName>{member.fullName}</MemberItemName>
|
||||||
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
|
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
|
||||||
</MemberListItemDetails>
|
</MemberListItemDetails>
|
||||||
<MemberItemOptions>
|
<MemberItemOptions>
|
||||||
<MemberItemOption variant="flat">On 6 projects</MemberItemOption>
|
<MemberItemOption variant="flat">On 6 projects</MemberItemOption>
|
||||||
<MemberItemOption
|
<MemberItemOption
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={$target => {
|
onClick={$target => {
|
||||||
showPopup(
|
showPopup(
|
||||||
$target,
|
$target,
|
||||||
<TeamRoleManagerPopup
|
<TeamRoleManagerPopup
|
||||||
user={member}
|
user={member}
|
||||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||||
updateUserPassword={(user, password) => {
|
updateUserPassword={(user, password) => {
|
||||||
onUpdateUserPassword(user, password)
|
onUpdateUserPassword(user, password);
|
||||||
}}
|
}}
|
||||||
canChangeRole={member.role && member.role.code !== 'owner'}
|
canChangeRole={member.role && member.role.code !== 'owner'}
|
||||||
onChangeRole={roleCode => {
|
onChangeRole={roleCode => {
|
||||||
updateUserRole({ variables: { userID: member.id, roleCode } })
|
updateUserRole({ variables: { userID: member.id, roleCode } });
|
||||||
}}
|
}}
|
||||||
onRemoveFromTeam={
|
onRemoveFromTeam={
|
||||||
member.role && member.role.code === 'owner'
|
member.role && member.role.code === 'owner'
|
||||||
? undefined
|
? undefined
|
||||||
: () => {
|
: () => {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Manage
|
Manage
|
||||||
</MemberItemOption>
|
</MemberItemOption>
|
||||||
</MemberItemOptions>
|
</MemberItemOptions>
|
||||||
</MemberListItem>
|
</MemberListItem>
|
||||||
))}
|
))}
|
||||||
</MemberList>
|
</MemberList>
|
||||||
</MemberListWrapper>
|
</MemberListWrapper>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</TabContentWrapper>
|
</TabContentWrapper>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Admin;
|
export default Admin;
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import styled, {css} from 'styled-components';
|
import styled, { css, keyframes } from 'styled-components';
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {mixin} from 'shared/utils/styles';
|
import { mixin } from 'shared/utils/styles';
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
import TextareaAutosize from 'react-autosize-textarea';
|
||||||
import {CheckCircle} from 'shared/icons';
|
import { CheckCircle } from 'shared/icons';
|
||||||
import {RefObject} from 'react';
|
import { RefObject } from 'react';
|
||||||
|
|
||||||
export const ClockIcon = styled(FontAwesomeIcon)``;
|
export const ClockIcon = styled(FontAwesomeIcon)``;
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ export const DescriptionBadge = styled(ListCardBadge)`
|
|||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const DueDateCardBadge = styled(ListCardBadge) <{isPastDue: boolean}>`
|
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
${props =>
|
${props =>
|
||||||
props.isPastDue &&
|
props.isPastDue &&
|
||||||
@ -76,7 +76,7 @@ export const ListCardBadgeText = styled.span`
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ListCardContainer = styled.div<{isActive: boolean; editable: boolean}>`
|
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
|
||||||
max-width: 256px;
|
max-width: 256px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@ -93,7 +93,7 @@ export const ListCardInnerContainer = styled.div`
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ListCardDetails = styled.div<{complete: boolean}>`
|
export const ListCardDetails = styled.div<{ complete: boolean }>`
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 6px 8px 2px;
|
padding: 6px 8px 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -102,28 +102,93 @@ export const ListCardDetails = styled.div<{complete: boolean}>`
|
|||||||
${props => props.complete && 'opacity: 0.6;'}
|
${props => props.complete && 'opacity: 0.6;'}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ListCardLabels = styled.div`
|
const labelVariantExpandAnimation = keyframes`
|
||||||
overflow: auto;
|
0% {min-width: 40px; height: 8px;}
|
||||||
position: relative;
|
50% {min-width: 56px; height: 8px;}
|
||||||
|
100% {min-width: 56px; height: 16px;}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ListCardLabel = styled.span`
|
const labelTextVariantExpandAnimation = keyframes`
|
||||||
height: 16px;
|
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;
|
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;
|
padding: 0 8px;
|
||||||
max-width: 198px;
|
max-width: 198px;
|
||||||
float: left;
|
float: left;
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 4px 4px 0;
|
margin: 0 4px 4px 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: block;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: ${props => props.color};
|
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`
|
export const ListCardOperation = styled.span`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
@ -136,7 +201,7 @@ export const ListCardOperation = styled.span`
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${props => mixin.darken('#262c49', .25)};
|
background-color: ${props => mixin.darken('#262c49', 0.25)};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import React, {useState, useRef, useEffect} from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||||
import {faPencilAlt, faList} from '@fortawesome/free-solid-svg-icons';
|
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
|
||||||
import {faClock, faCheckSquare, faEye} from '@fortawesome/free-regular-svg-icons';
|
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
|
||||||
import {
|
import {
|
||||||
EditorTextarea,
|
EditorTextarea,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
@ -18,6 +18,7 @@ import {
|
|||||||
ClockIcon,
|
ClockIcon,
|
||||||
ListCardLabels,
|
ListCardLabels,
|
||||||
ListCardLabel,
|
ListCardLabel,
|
||||||
|
ListCardLabelText,
|
||||||
ListCardOperation,
|
ListCardOperation,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardMembers,
|
CardMembers,
|
||||||
@ -47,10 +48,12 @@ type Props = {
|
|||||||
watched?: boolean;
|
watched?: boolean;
|
||||||
wrapperProps?: any;
|
wrapperProps?: any;
|
||||||
members?: Array<TaskUser> | null;
|
members?: Array<TaskUser> | null;
|
||||||
|
onCardLabelClick?: () => void;
|
||||||
onCardMemberClick?: OnCardMemberClick;
|
onCardMemberClick?: OnCardMemberClick;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
|
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
|
||||||
onCardTitleChange?: (name: string) => void;
|
onCardTitleChange?: (name: string) => void;
|
||||||
|
labelVariant?: CardLabelVariant;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Card = React.forwardRef(
|
const Card = React.forwardRef(
|
||||||
@ -69,14 +72,20 @@ const Card = React.forwardRef(
|
|||||||
checklists,
|
checklists,
|
||||||
watched,
|
watched,
|
||||||
members,
|
members,
|
||||||
|
labelVariant,
|
||||||
onCardMemberClick,
|
onCardMemberClick,
|
||||||
editable,
|
editable,
|
||||||
|
onCardLabelClick,
|
||||||
onEditCard,
|
onEditCard,
|
||||||
onCardTitleChange,
|
onCardTitleChange,
|
||||||
}: Props,
|
}: Props,
|
||||||
$cardRef: any,
|
$cardRef: any,
|
||||||
) => {
|
) => {
|
||||||
const [currentCardTitle, setCardTitle] = useState(title);
|
const [currentCardTitle, setCardTitle] = useState(title);
|
||||||
|
const [toggleLabels, setToggleLabels] = useState(false);
|
||||||
|
const [toggleDirection, setToggleDirection] = useState<'shrink' | 'expand'>(
|
||||||
|
labelVariant === 'large' ? 'shrink' : 'expand',
|
||||||
|
);
|
||||||
const $editorRef: any = useRef();
|
const $editorRef: any = useRef();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -132,21 +141,39 @@ const Card = React.forwardRef(
|
|||||||
>
|
>
|
||||||
<ListCardInnerContainer ref={$innerCardRef}>
|
<ListCardInnerContainer ref={$innerCardRef}>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<ListCardOperation onClick={e => {
|
<ListCardOperation
|
||||||
e.stopPropagation();
|
onClick={e => {
|
||||||
if (onContextMenu) {
|
e.stopPropagation();
|
||||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
if (onContextMenu) {
|
||||||
}
|
onContextMenu($innerCardRef, taskID, taskGroupID);
|
||||||
}}>
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
||||||
</ListCardOperation>
|
</ListCardOperation>
|
||||||
)}
|
)}
|
||||||
<ListCardDetails complete={complete ?? false}>
|
<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 &&
|
||||||
labels.map(label => (
|
labels.map(label => (
|
||||||
<ListCardLabel color={label.labelColor.colorHex} key={label.id}>
|
<ListCardLabel
|
||||||
{label.name}
|
onAnimationEnd={() => setToggleLabels(false)}
|
||||||
|
variant={labelVariant ?? 'large'}
|
||||||
|
color={label.labelColor.colorHex}
|
||||||
|
key={label.id}
|
||||||
|
>
|
||||||
|
<ListCardLabelText>{label.name}</ListCardLabelText>
|
||||||
</ListCardLabel>
|
</ListCardLabel>
|
||||||
))}
|
))}
|
||||||
</ListCardLabels>
|
</ListCardLabels>
|
||||||
@ -169,11 +196,11 @@ const Card = React.forwardRef(
|
|||||||
/>
|
/>
|
||||||
</EditorContent>
|
</EditorContent>
|
||||||
) : (
|
) : (
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{complete && <CompleteIcon width={16} height={16} />}
|
{complete && <CompleteIcon width={16} height={16} />}
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
)}
|
)}
|
||||||
<ListCardBadges>
|
<ListCardBadges>
|
||||||
{watched && (
|
{watched && (
|
||||||
<ListCardBadge>
|
<ListCardBadge>
|
||||||
|
@ -17,7 +17,7 @@ import {
|
|||||||
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { getYear, getMonth } from 'date-fns';
|
import { getYear, getMonth } from 'date-fns';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
|
||||||
type DueDateManagerProps = {
|
type DueDateManagerProps = {
|
||||||
task: Task;
|
task: Task;
|
||||||
@ -147,7 +147,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
'November',
|
'November',
|
||||||
'December',
|
'December',
|
||||||
];
|
];
|
||||||
const { register, handleSubmit, errors, setValue, setError, formState } = useForm<DueDateFormData>();
|
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
|
||||||
const saveDueDate = (data: any) => {
|
const saveDueDate = (data: any) => {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
|
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());
|
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) => {
|
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
|
||||||
return (
|
return (
|
||||||
<DueDateInput
|
<DueDateInput
|
||||||
id="endTime"
|
id="endTime"
|
||||||
name="endTime"
|
name="endTime"
|
||||||
ref={$ref}
|
ref={$ref}
|
||||||
onChange={e => {
|
|
||||||
console.log(`onCahnge ${e.currentTarget.value}`);
|
|
||||||
setTextEndTime(e.currentTarget.value);
|
|
||||||
setValue('endTime', e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
width="100%"
|
width="100%"
|
||||||
variant="alternate"
|
variant="alternate"
|
||||||
label="Date"
|
label="Date"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
value={value}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -190,30 +179,29 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
width="100%"
|
width="100%"
|
||||||
variant="alternate"
|
variant="alternate"
|
||||||
label="Date"
|
label="Date"
|
||||||
onChange={e => {
|
defaultValue={textStartDate}
|
||||||
setTextStartDate(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
value={textStartDate}
|
|
||||||
ref={register({
|
ref={register({
|
||||||
required: 'End date is required.',
|
required: 'End date is required.',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField>
|
<FormField>
|
||||||
<DatePicker
|
<Controller
|
||||||
selected={endTime}
|
control={control}
|
||||||
onChange={date => {
|
name="endTime"
|
||||||
const changedDate = moment(date ?? new Date());
|
render={({ onChange, onBlur, value }) => (
|
||||||
console.log(`changed ${date}`);
|
<DatePicker
|
||||||
setEndTime(changedDate.toDate());
|
onChange={onChange}
|
||||||
setValue('endTime', changedDate.format('h:mm A'));
|
selected={value}
|
||||||
}}
|
onBlur={onBlur}
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
showTimeSelectOnly
|
showTimeSelectOnly
|
||||||
timeIntervals={15}
|
timeIntervals={15}
|
||||||
timeCaption="Time"
|
timeCaption="Time"
|
||||||
dateFormat="h:mm aa"
|
dateFormat="h:mm aa"
|
||||||
customInput={<CustomTimeInput />}
|
customInput={<CustomTimeInput />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<DueDatePickerWrapper>
|
<DueDatePickerWrapper>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, {useState, useEffect} from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import styled, {css} from 'styled-components/macro';
|
import styled, { css } from 'styled-components/macro';
|
||||||
|
|
||||||
const InputWrapper = styled.div<{width: string}>`
|
const InputWrapper = styled.div<{ width: string }>`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: ${props => props.width};
|
width: ${props => props.width};
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -14,7 +14,7 @@ const InputWrapper = styled.div<{width: string}>`
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const InputLabel = styled.span<{width: string}>`
|
const InputLabel = styled.span<{ width: string }>`
|
||||||
width: ${props => props.width};
|
width: ${props => props.width};
|
||||||
padding: 0.7rem !important;
|
padding: 0.7rem !important;
|
||||||
color: #c2c6dc;
|
color: #c2c6dc;
|
||||||
@ -87,9 +87,8 @@ type InputProps = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
value?: string;
|
defaultValue?: string;
|
||||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Input = React.forwardRef(
|
const Input = React.forwardRef(
|
||||||
@ -102,40 +101,34 @@ const Input = React.forwardRef(
|
|||||||
placeholder,
|
placeholder,
|
||||||
icon,
|
icon,
|
||||||
name,
|
name,
|
||||||
onChange,
|
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
floatingLabel,
|
floatingLabel,
|
||||||
value: initialValue,
|
defaultValue,
|
||||||
id,
|
id,
|
||||||
}: InputProps,
|
}: InputProps,
|
||||||
$ref: any,
|
$ref: any,
|
||||||
) => {
|
) => {
|
||||||
const [value, setValue] = useState(initialValue ?? '');
|
const [hasValue, setHasValue] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
if (initialValue) {
|
|
||||||
setValue(initialValue);
|
|
||||||
}
|
|
||||||
}, [initialValue]);
|
|
||||||
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
|
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 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 (
|
return (
|
||||||
<InputWrapper className={className} width={width}>
|
<InputWrapper className={className} width={width}>
|
||||||
<InputInput
|
<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}
|
ref={$ref}
|
||||||
id={id}
|
id={id}
|
||||||
name={name}
|
name={name}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onChange={handleChange}
|
|
||||||
autoComplete={autocomplete ? 'on' : 'off'}
|
autoComplete={autocomplete ? 'on' : 'off'}
|
||||||
value={value}
|
defaultValue={defaultValue}
|
||||||
hasIcon={typeof icon !== 'undefined'}
|
hasIcon={typeof icon !== 'undefined'}
|
||||||
width={width}
|
width={width}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
@ -93,6 +93,8 @@ export const Default = () => {
|
|||||||
onTaskDrop={onCardDrop}
|
onTaskDrop={onCardDrop}
|
||||||
onTaskGroupDrop={onListDrop}
|
onTaskGroupDrop={onListDrop}
|
||||||
onChangeTaskGroupName={action('change group name')}
|
onChangeTaskGroupName={action('change group name')}
|
||||||
|
cardLabelVariant="large"
|
||||||
|
onCardLabelClick={action('label click')}
|
||||||
onCreateTaskGroup={action('create list')}
|
onCreateTaskGroup={action('create list')}
|
||||||
onExtraMenuOpen={action('extra menu open')}
|
onExtraMenuOpen={action('extra menu open')}
|
||||||
onCardMemberClick={action('card member click')}
|
onCardMemberClick={action('card member click')}
|
||||||
|
@ -26,17 +26,21 @@ interface SimpleProps {
|
|||||||
onCreateTaskGroup: (listName: string) => void;
|
onCreateTaskGroup: (listName: string) => void;
|
||||||
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
onCardMemberClick: OnCardMemberClick;
|
onCardMemberClick: OnCardMemberClick;
|
||||||
|
onCardLabelClick: () => void;
|
||||||
|
cardLabelVariant: CardLabelVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SimpleLists: React.FC<SimpleProps> = ({
|
const SimpleLists: React.FC<SimpleProps> = ({
|
||||||
taskGroups,
|
taskGroups,
|
||||||
onTaskDrop,
|
onTaskDrop,
|
||||||
onChangeTaskGroupName,
|
onChangeTaskGroupName,
|
||||||
|
onCardLabelClick,
|
||||||
onTaskGroupDrop,
|
onTaskGroupDrop,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onQuickEditorOpen,
|
onQuickEditorOpen,
|
||||||
onCreateTaskGroup,
|
onCreateTaskGroup,
|
||||||
|
cardLabelVariant,
|
||||||
onExtraMenuOpen,
|
onExtraMenuOpen,
|
||||||
onCardMemberClick,
|
onCardMemberClick,
|
||||||
}) => {
|
}) => {
|
||||||
@ -158,10 +162,12 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
|||||||
{taskProvided => {
|
{taskProvided => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
labelVariant={cardLabelVariant}
|
||||||
wrapperProps={{
|
wrapperProps={{
|
||||||
...taskProvided.draggableProps,
|
...taskProvided.draggableProps,
|
||||||
...taskProvided.dragHandleProps,
|
...taskProvided.dragHandleProps,
|
||||||
}}
|
}}
|
||||||
|
onCardLabelClick={onCardLabelClick}
|
||||||
ref={taskProvided.innerRef}
|
ref={taskProvided.innerRef}
|
||||||
taskID={task.id}
|
taskID={task.id}
|
||||||
complete={task.complete ?? false}
|
complete={task.complete ?? false}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Checkmark } from 'shared/icons';
|
import { Checkmark } from 'shared/icons';
|
||||||
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
||||||
|
|
||||||
const WhiteCheckmark = styled(Checkmark)`
|
const WhiteCheckmark = styled(Checkmark)`
|
||||||
fill: rgba(${props => props.theme.colors.text.secondary});
|
fill: rgba(${props => props.theme.colors.text.secondary});
|
||||||
@ -39,17 +39,19 @@ const LabelManager = ({ labelColors, label, onLabelEdit, onLabelDelete }: Props)
|
|||||||
/>
|
/>
|
||||||
<FieldLabel>Select a color</FieldLabel>
|
<FieldLabel>Select a color</FieldLabel>
|
||||||
<div>
|
<div>
|
||||||
{labelColors.map((labelColor: LabelColor) => (
|
{labelColors
|
||||||
<LabelBox
|
.filter(l => l.name !== 'no_color')
|
||||||
key={labelColor.id}
|
.map((labelColor: LabelColor) => (
|
||||||
color={labelColor.colorHex}
|
<LabelBox
|
||||||
onClick={() => {
|
key={labelColor.id}
|
||||||
setCurrentColor(labelColor);
|
color={labelColor.colorHex}
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
setCurrentColor(labelColor);
|
||||||
{currentColor && labelColor.id === currentColor.id && <WhiteCheckmark width={12} height={12} />}
|
}}
|
||||||
</LabelBox>
|
>
|
||||||
))}
|
{currentColor && labelColor.id === currentColor.id && <WhiteCheckmark width={12} height={12} />}
|
||||||
|
</LabelBox>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SaveButton
|
<SaveButton
|
||||||
|
@ -40,7 +40,7 @@ export const ListSeparator = styled.hr`
|
|||||||
type Props = {
|
type Props = {
|
||||||
onDeleteProject: () => void;
|
onDeleteProject: () => void;
|
||||||
};
|
};
|
||||||
const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
|
const ProjectSettings: React.FC<Props> = ({onDeleteProject}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListActionsWrapper>
|
<ListActionsWrapper>
|
||||||
@ -55,7 +55,7 @@ const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
|
|||||||
type TeamSettingsProps = {
|
type TeamSettingsProps = {
|
||||||
onDeleteTeam: () => void;
|
onDeleteTeam: () => void;
|
||||||
};
|
};
|
||||||
export const TeamSettings: React.FC<TeamSettingsProps> = ({ onDeleteTeam }) => {
|
export const TeamSettings: React.FC<TeamSettingsProps> = ({onDeleteTeam}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListActionsWrapper>
|
<ListActionsWrapper>
|
||||||
@ -74,6 +74,7 @@ const ConfirmSubTitle = styled.h3`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const ConfirmDescription = styled.div`
|
const ConfirmDescription = styled.div`
|
||||||
|
margin: 0 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -83,7 +84,7 @@ const DeleteList = styled.ul`
|
|||||||
const DeleteListItem = styled.li`
|
const DeleteListItem = styled.li`
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
list-style: disc;
|
list-style: disc;
|
||||||
margin-left: 12px;
|
margin-left: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ConfirmDeleteButton = styled(Button)`
|
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 (
|
return (
|
||||||
<ConfirmWrapper>
|
<ConfirmWrapper>
|
||||||
<ConfirmDescription>
|
<ConfirmDescription>
|
||||||
@ -126,5 +127,5 @@ const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { DeleteConfirm };
|
export {DeleteConfirm};
|
||||||
export default ProjectSettings;
|
export default ProjectSettings;
|
||||||
|
103
frontend/src/shared/components/Register/Styles.ts
Normal file
103
frontend/src/shared/components/Register/Styles.ts
Normal 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);
|
||||||
|
`;
|
150
frontend/src/shared/components/Register/index.tsx
Normal file
150
frontend/src/shared/components/Register/index.tsx
Normal 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;
|
@ -263,13 +263,13 @@ const Settings: React.FC<SettingsProps> = ({ onProfileAvatarRemove, onProfileAva
|
|||||||
onProfileAvatarChange={onProfileAvatarChange}
|
onProfileAvatarChange={onProfileAvatarChange}
|
||||||
profile={profile.profileIcon}
|
profile={profile.profileIcon}
|
||||||
/>
|
/>
|
||||||
<Input value={profile.fullName} width="100%" label="Name" />
|
<Input defaultValue={profile.fullName} width="100%" label="Name" />
|
||||||
<Input
|
<Input
|
||||||
value={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
||||||
width="100%"
|
width="100%"
|
||||||
label="Initials "
|
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="Email" />
|
||||||
<Input width="100%" label="Bio" />
|
<Input width="100%" label="Bio" />
|
||||||
<SettingActions>
|
<SettingActions>
|
||||||
|
@ -267,6 +267,7 @@ export type Mutation = {
|
|||||||
updateTaskLocation: UpdateTaskLocationPayload;
|
updateTaskLocation: UpdateTaskLocationPayload;
|
||||||
updateTaskName: Task;
|
updateTaskName: Task;
|
||||||
updateTeamMemberRole: UpdateTeamMemberRolePayload;
|
updateTeamMemberRole: UpdateTeamMemberRolePayload;
|
||||||
|
updateUserPassword: UpdateUserPasswordPayload;
|
||||||
updateUserRole: UpdateUserRolePayload;
|
updateUserRole: UpdateUserRolePayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -506,6 +507,11 @@ export type MutationUpdateTeamMemberRoleArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationUpdateUserPasswordArgs = {
|
||||||
|
input: UpdateUserPassword;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationUpdateUserRoleArgs = {
|
export type MutationUpdateUserRoleArgs = {
|
||||||
input: UpdateUserRole;
|
input: UpdateUserRole;
|
||||||
};
|
};
|
||||||
@ -862,6 +868,17 @@ export type SetTeamOwnerPayload = {
|
|||||||
newOwner: Member;
|
newOwner: Member;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateUserPassword = {
|
||||||
|
userID: Scalars['UUID'];
|
||||||
|
password: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateUserPasswordPayload = {
|
||||||
|
__typename?: 'UpdateUserPasswordPayload';
|
||||||
|
ok: Scalars['Boolean'];
|
||||||
|
user: UserAccount;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateUserRole = {
|
export type UpdateUserRole = {
|
||||||
userID: Scalars['UUID'];
|
userID: Scalars['UUID'];
|
||||||
roleCode: RoleCode;
|
roleCode: RoleCode;
|
||||||
|
@ -13581,10 +13581,10 @@ react-helmet-async@^1.0.2:
|
|||||||
react-fast-compare "^2.0.4"
|
react-fast-compare "^2.0.4"
|
||||||
shallowequal "^1.1.0"
|
shallowequal "^1.1.0"
|
||||||
|
|
||||||
react-hook-form@^5.2.0:
|
react-hook-form@^6.0.6:
|
||||||
version "5.2.0"
|
version "6.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.2.0.tgz#b5b654516ee03d55d78b7b9e194c7f4632885426"
|
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.0.6.tgz#72ac1668aeaddfd642bcfb324cebe1ba237fb13e"
|
||||||
integrity sha512-EqGCSl3DxSUBtL/9lFvrFQLJ7ICdVKrfjcMHay2SvmU4trR8aqrd7YuiLSojBKmZBRdBnCcxG+LzLWF9z474NA==
|
integrity sha512-qxWhV++1V7SKKlr2hHFsessGwATCdexgVsByxOHltDyO9F0VWB1WN4ZvxnKuHTVGwjj6CLZo0mL+Hgy0QH1sAw==
|
||||||
|
|
||||||
react-hotkeys@2.0.0:
|
react-hotkeys@2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
|
@ -9,8 +9,16 @@ import (
|
|||||||
|
|
||||||
var jwtKey = []byte("citadel_test_key")
|
var jwtKey = []byte("citadel_test_key")
|
||||||
|
|
||||||
|
type RestrictedMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Unrestricted RestrictedMode = "unrestricted"
|
||||||
|
InstallOnly = "install_only"
|
||||||
|
)
|
||||||
|
|
||||||
type AccessTokenClaims struct {
|
type AccessTokenClaims struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
|
Restricted RestrictedMode `json:"restricted"`
|
||||||
jwt.StandardClaims
|
jwt.StandardClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,10 +39,11 @@ func (r *ErrMalformedToken) Error() string {
|
|||||||
return "token is malformed"
|
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)
|
accessExpirationTime := time.Now().Add(5 * time.Second)
|
||||||
accessClaims := &AccessTokenClaims{
|
accessClaims := &AccessTokenClaims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
Restricted: restrictedMode,
|
||||||
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +59,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
|
|||||||
accessExpirationTime := time.Now().Add(dur)
|
accessExpirationTime := time.Now().Add(dur)
|
||||||
accessClaims := &AccessTokenClaims{
|
accessClaims := &AccessTokenClaims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
Restricted: Unrestricted,
|
||||||
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,6 +58,12 @@ type Role struct {
|
|||||||
Name string `json:"name"`
|
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 {
|
type Task struct {
|
||||||
TaskID uuid.UUID `json:"task_id"`
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
TaskGroupID uuid.UUID `json:"task_group_id"`
|
TaskGroupID uuid.UUID `json:"task_group_id"`
|
||||||
|
@ -15,6 +15,7 @@ type Querier interface {
|
|||||||
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
|
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
|
||||||
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
|
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
|
||||||
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, 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)
|
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
|
||||||
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
|
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
|
||||||
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
|
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
|
||||||
@ -61,6 +62,7 @@ type Querier interface {
|
|||||||
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
|
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
|
||||||
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
|
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
|
||||||
GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, 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)
|
GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error)
|
||||||
GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error)
|
GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error)
|
||||||
GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error)
|
GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error)
|
||||||
|
5
internal/db/query/system_options.sql
Normal file
5
internal/db/query/system_options.sql
Normal 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 *;
|
41
internal/db/system_options.sql.go
Normal file
41
internal/db/system_options.sql.go
Normal 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
|
||||||
|
}
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||||
"github.com/99designs/gqlgen/graphql/playground"
|
"github.com/99designs/gqlgen/graphql/playground"
|
||||||
"github.com/google/uuid"
|
"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/config"
|
||||||
"github.com/jordanknott/project-citadel/api/internal/db"
|
"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 {
|
func NewPlaygroundHandler(endpoint string) http.Handler {
|
||||||
return playground.Handler("GraphQL Playground", endpoint)
|
return playground.Handler("GraphQL Playground", endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
|
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
|
||||||
userID, ok := ctx.Value("userID").(uuid.UUID)
|
userID, ok := ctx.Value("userID").(uuid.UUID)
|
||||||
return userID, ok
|
return userID, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) {
|
||||||
|
restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode)
|
||||||
|
return restricted, ok
|
||||||
|
}
|
||||||
|
@ -706,6 +706,7 @@ func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTea
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return &DeleteTeamMemberPayload{}, err
|
return &DeleteTeamMemberPayload{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID})
|
_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &DeleteTeamMemberPayload{}, err
|
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")
|
return &db.UserAccount{}, fmt.Errorf("internal server error")
|
||||||
}
|
}
|
||||||
user, err := r.Repository.GetUserAccountByID(ctx, userID)
|
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 &db.UserAccount{}, err
|
||||||
}
|
}
|
||||||
return &user, err
|
return &user, err
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
package route
|
package route
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dgrijalva/jwt-go"
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jordanknott/project-citadel/api/internal/auth"
|
"github.com/jordanknott/project-citadel/api/internal/auth"
|
||||||
@ -18,23 +18,26 @@ var jwtKey = []byte("citadel_test_key")
|
|||||||
|
|
||||||
type authResource struct{}
|
type authResource struct{}
|
||||||
|
|
||||||
type AccessTokenClaims struct {
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
jwt.StandardClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
type RefreshTokenClaims struct {
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
jwt.StandardClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginRequestData struct {
|
type LoginRequestData struct {
|
||||||
Username string
|
Username string
|
||||||
Password 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 {
|
type LoginResponseData struct {
|
||||||
AccessToken string `json:"accessToken"`
|
AccessToken string `json:"accessToken"`
|
||||||
|
IsInstalled bool `json:"isInstalled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogoutResponseData struct {
|
type LogoutResponseData struct {
|
||||||
@ -51,18 +54,48 @@ type AvatarUploadResponseData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
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")
|
c, err := r.Cookie("refreshToken")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == http.ErrNoCookie {
|
if err == http.ErrNoCookie {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.WithError(err).Error("unknown error")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refreshTokenID := uuid.MustParse(c.Value)
|
refreshTokenID := uuid.MustParse(c.Value)
|
||||||
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
|
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
|
||||||
if err != nil {
|
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)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -76,7 +109,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
|
|||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
accessTokenString, err := auth.NewAccessToken(token.UserID.String())
|
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@ -88,7 +121,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
|
|||||||
Expires: refreshExpiresAt,
|
Expires: refreshExpiresAt,
|
||||||
HttpOnly: true,
|
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) {
|
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)
|
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||||
|
|
||||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String())
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@ -154,7 +187,68 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Expires: refreshExpiresAt,
|
Expires: refreshExpiresAt,
|
||||||
HttpOnly: true,
|
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 {
|
func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router {
|
||||||
|
@ -40,13 +40,19 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := uuid.Parse(accessClaims.UserID)
|
var userID uuid.UUID
|
||||||
if err != nil {
|
if accessClaims.Restricted == auth.InstallOnly {
|
||||||
log.Error(err)
|
userID = uuid.New()
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
} else {
|
||||||
return
|
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(r.Context(), "userID", userID)
|
||||||
|
ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted)
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
|
@ -104,6 +104,7 @@ func NewRouter(config config.AppConfig, dbConnection *sqlx.DB) (chi.Router, erro
|
|||||||
r.Group(func(mux chi.Router) {
|
r.Group(func(mux chi.Router) {
|
||||||
mux.Use(AuthenticationMiddleware)
|
mux.Use(AuthenticationMiddleware)
|
||||||
mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload)
|
mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload)
|
||||||
|
mux.Post("/auth/install", citadelHandler.InstallHandler)
|
||||||
mux.Handle("/graphql", graph.NewHandler(config, *repository))
|
mux.Handle("/graphql", graph.NewHandler(config, *repository))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
5
migrations/0045_add-system_options-table.up.sql
Normal file
5
migrations/0045_add-system_options-table.up.sql
Normal 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
|
||||||
|
);
|
2
migrations/0046_add-system-user.up.sql
Normal file
2
migrations/0046_add-system-user.up.sql
Normal 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', '');
|
16
migrations/0047_add-default-label_colors.up.sql
Normal file
16
migrations/0047_add-default-label_colors.up.sql
Normal 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' );
|
@ -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;
|
@ -0,0 +1,27 @@
|
|||||||
|
+--------------------+--------------------------+--------------------------------------+
|
||||||
|
| [38;5;47;01mColumn[39;00m | [38;5;47;01mType[39;00m | [38;5;47;01mModifiers[39;00m |
|
||||||
|
|--------------------+--------------------------+--------------------------------------|
|
||||||
|
| 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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user