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 />
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -92,7 +92,7 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
|
|||||||
<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"
|
||||||
@ -194,8 +194,10 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
cache =>
|
cache =>
|
||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
const { checklists } = cache.findTask;
|
const { checklists } = cache.findTask;
|
||||||
console.log(deleteData)
|
console.log(deleteData);
|
||||||
draftCache.findTask.checklists = checklists.filter(c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id);
|
draftCache.findTask.checklists = checklists.filter(
|
||||||
|
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
|
||||||
|
);
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||||
draftCache.findTask.badges.checklist = {
|
draftCache.findTask.badges.checklist = {
|
||||||
__typename: 'ChecklistBadge',
|
__typename: 'ChecklistBadge',
|
||||||
@ -234,9 +236,11 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
cache =>
|
cache =>
|
||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
||||||
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID)
|
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
|
||||||
if (targetIdx > -1) {
|
if (targetIdx > -1) {
|
||||||
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(c => item.id !== c.id);
|
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
|
||||||
|
c => item.id !== c.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||||
draftCache.findTask.badges.checklist = {
|
draftCache.findTask.badges.checklist = {
|
||||||
@ -372,9 +376,9 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
__typename: 'TaskChecklistItem',
|
__typename: 'TaskChecklistItem',
|
||||||
id: itemID,
|
id: itemID,
|
||||||
taskChecklistID: checklistID,
|
taskChecklistID: checklistID,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onToggleChecklistItem={(itemID, complete) => {
|
onToggleChecklistItem={(itemID, complete) => {
|
||||||
@ -398,7 +402,7 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
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"
|
||||||
@ -413,7 +417,7 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
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}
|
||||||
@ -500,7 +504,7 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}}
|
}}
|
||||||
onCancel={() => { }}
|
onCancel={() => {}}
|
||||||
/>
|
/>
|
||||||
</Popup>,
|
</Popup>,
|
||||||
);
|
);
|
||||||
|
@ -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}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -121,7 +121,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
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}>
|
||||||
@ -137,9 +137,13 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
||||||
</MiniProfileActionItem>
|
</MiniProfileActionItem>
|
||||||
)}
|
)}
|
||||||
<MiniProfileActionItem onClick={() => {
|
<MiniProfileActionItem
|
||||||
setTab(3)
|
onClick={() => {
|
||||||
}}>Reset password...</MiniProfileActionItem>
|
setTab(3);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset password...
|
||||||
|
</MiniProfileActionItem>
|
||||||
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
|
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
|
||||||
</MiniProfileActionWrapper>
|
</MiniProfileActionWrapper>
|
||||||
</MiniProfileActions>
|
</MiniProfileActions>
|
||||||
@ -214,23 +218,39 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
<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...
|
||||||
|
</UserPassButton>
|
||||||
|
<UserPassButton color="warning" variant="outline">
|
||||||
|
Send reset link
|
||||||
|
</UserPassButton>
|
||||||
</UserPassBar>
|
</UserPassBar>
|
||||||
</Content>
|
</Content>
|
||||||
</Popup>
|
</Popup>
|
||||||
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
||||||
<Content>
|
<Content>
|
||||||
<NewUserPassInput onChange={e => setUserPass({ pass: e.currentTarget.value, passConfirm: userPass.passConfirm })} value={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
<NewUserPassInput defaultValue={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
||||||
<NewUserPassInput onChange={e => setUserPass({ passConfirm: e.currentTarget.value, pass: userPass.pass })} value={userPass.passConfirm} width="100%" variant="alternate" placeholder="New password (confirm)" />
|
<NewUserPassInput
|
||||||
<UserPassConfirmButton disabled={userPass.pass === "" || userPass.passConfirm === ""} onClick={() => {
|
defaultValue={userPass.passConfirm}
|
||||||
|
width="100%"
|
||||||
|
variant="alternate"
|
||||||
|
placeholder="New password (confirm)"
|
||||||
|
/>
|
||||||
|
<UserPassConfirmButton
|
||||||
|
disabled={userPass.pass === '' || userPass.passConfirm === ''}
|
||||||
|
onClick={() => {
|
||||||
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
|
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
|
||||||
updateUserPassword(user, userPass.pass)
|
updateUserPassword(user, userPass.pass);
|
||||||
}
|
}
|
||||||
}} color="danger">Set password</UserPassConfirmButton>
|
}}
|
||||||
|
color="danger"
|
||||||
|
>
|
||||||
|
Set password
|
||||||
|
</UserPassConfirmButton>
|
||||||
</Content>
|
</Content>
|
||||||
</Popup>
|
</Popup>
|
||||||
<Popup title="Remove user" onClose={() => hidePopup()} tab={5}>
|
<Popup title="Remove user" onClose={() => hidePopup()} tab={5}>
|
||||||
@ -238,36 +258,36 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
<DeleteDescription>
|
<DeleteDescription>
|
||||||
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
||||||
</DeleteDescription>
|
</DeleteDescription>
|
||||||
<DeleteDescription>
|
<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%;
|
||||||
@ -275,7 +295,7 @@ const UserPassButton = styled(Button)`
|
|||||||
& ~ & {
|
& ~ & {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
const MemberItemOptions = styled.div``;
|
const MemberItemOptions = styled.div``;
|
||||||
|
|
||||||
@ -434,7 +454,7 @@ const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
|
|||||||
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)}>
|
||||||
@ -639,7 +659,14 @@ type AdminProps = {
|
|||||||
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> = ({
|
||||||
|
initialTab,
|
||||||
|
onAddUser,
|
||||||
|
onUpdateUserPassword,
|
||||||
|
onDeleteUser,
|
||||||
|
onInviteUser,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
const warning =
|
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”.';
|
'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 [currentTop, setTop] = useState(initialTab * 48);
|
||||||
@ -647,7 +674,7 @@ const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPasswo
|
|||||||
const { showPopup, hidePopup } = usePopup();
|
const { showPopup, hidePopup } = usePopup();
|
||||||
const $tabNav = useRef<HTMLDivElement>(null);
|
const $tabNav = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [updateUserRole] = useUpdateUserRoleMutation()
|
const [updateUserRole] = useUpdateUserRoleMutation();
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<TabNav ref={$tabNav}>
|
<TabNav ref={$tabNav}>
|
||||||
@ -692,7 +719,7 @@ const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPasswo
|
|||||||
<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>
|
||||||
@ -708,11 +735,11 @@ const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPasswo
|
|||||||
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'
|
||||||
|
@ -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
|
||||||
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onContextMenu) {
|
if (onContextMenu) {
|
||||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
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>
|
||||||
|
@ -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,24 +179,21 @@ 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>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="endTime"
|
||||||
|
render={({ onChange, onBlur, value }) => (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
selected={endTime}
|
onChange={onChange}
|
||||||
onChange={date => {
|
selected={value}
|
||||||
const changedDate = moment(date ?? new Date());
|
onBlur={onBlur}
|
||||||
console.log(`changed ${date}`);
|
|
||||||
setEndTime(changedDate.toDate());
|
|
||||||
setValue('endTime', changedDate.format('h:mm A'));
|
|
||||||
}}
|
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
showTimeSelectOnly
|
showTimeSelectOnly
|
||||||
timeIntervals={15}
|
timeIntervals={15}
|
||||||
@ -215,6 +201,8 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
dateFormat="h:mm aa"
|
dateFormat="h:mm aa"
|
||||||
customInput={<CustomTimeInput />}
|
customInput={<CustomTimeInput />}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<DueDatePickerWrapper>
|
<DueDatePickerWrapper>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
|
@ -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,7 +39,9 @@ const LabelManager = ({ labelColors, label, onLabelEdit, onLabelDelete }: Props)
|
|||||||
/>
|
/>
|
||||||
<FieldLabel>Select a color</FieldLabel>
|
<FieldLabel>Select a color</FieldLabel>
|
||||||
<div>
|
<div>
|
||||||
{labelColors.map((labelColor: LabelColor) => (
|
{labelColors
|
||||||
|
.filter(l => l.name !== 'no_color')
|
||||||
|
.map((labelColor: LabelColor) => (
|
||||||
<LabelBox
|
<LabelBox
|
||||||
key={labelColor.id}
|
key={labelColor.id}
|
||||||
color={labelColor.colorHex}
|
color={labelColor.colorHex}
|
||||||
|
@ -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 accessClaims.Restricted == auth.InstallOnly {
|
||||||
|
userID = uuid.New()
|
||||||
|
} else {
|
||||||
|
userID, err = uuid.Parse(accessClaims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.WithError(err).Error("middleware access token userID parse")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
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