feature: add first time install process
This commit is contained in:
parent
90515f6aa4
commit
2cf6be082c
@ -24,7 +24,7 @@
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"prettier/prettier": "warning",
|
||||
"prettier/prettier": "error",
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
@ -61,7 +61,7 @@
|
||||
"react-beautiful-dnd": "^13.0.0",
|
||||
"react-datepicker": "^2.14.1",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-hook-form": "^5.2.0",
|
||||
"react-hook-form": "^6.0.6",
|
||||
"react-markdown": "^4.3.1",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Router, Switch, Route } from 'react-router-dom';
|
||||
import {Router, Switch, Route} from 'react-router-dom';
|
||||
import * as H from 'history';
|
||||
|
||||
import Dashboard from 'Dashboard';
|
||||
@ -8,6 +8,7 @@ import Projects from 'Projects';
|
||||
import Project from 'Projects/Project';
|
||||
import Teams from 'Teams';
|
||||
import Login from 'Auth';
|
||||
import Install from 'Install';
|
||||
import Profile from 'Profile';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@ -23,9 +24,10 @@ type RoutesProps = {
|
||||
history: H.History;
|
||||
};
|
||||
|
||||
const Routes = ({ history }: RoutesProps) => (
|
||||
const Routes = ({history}: RoutesProps) => (
|
||||
<Switch>
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route exact path="/install" component={Install} />
|
||||
<MainContent>
|
||||
<Route exact path="/" component={Dashboard} />
|
||||
<Route exact path="/projects" component={Projects} />
|
||||
|
@ -196,7 +196,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({history, name, projec
|
||||
<Popup title={null} tab={0}>
|
||||
<ProjectSettings
|
||||
onDeleteProject={() => {
|
||||
setTab(1);
|
||||
setTab(1, 300);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
|
@ -1,18 +1,22 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import {createBrowserHistory} from 'history';
|
||||
import {setAccessToken} from 'shared/utils/accessToken';
|
||||
import styled, {ThemeProvider} from 'styled-components';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { Router } from 'react-router';
|
||||
import { PopupProvider } from 'shared/components/PopupMenu';
|
||||
import { setAccessToken } from 'shared/utils/accessToken';
|
||||
import styled, { ThemeProvider } from 'styled-components';
|
||||
import NormalizeStyles from './NormalizeStyles';
|
||||
import BaseStyles from './BaseStyles';
|
||||
import {theme} from './ThemeStyles';
|
||||
import { theme } from './ThemeStyles';
|
||||
import Routes from './Routes';
|
||||
import {UserIDContext} from './context';
|
||||
import { UserIDContext } from './context';
|
||||
import Navbar from './Navbar';
|
||||
import {Router} from 'react-router';
|
||||
import {PopupProvider} from 'shared/components/PopupMenu';
|
||||
|
||||
const history = createBrowserHistory();
|
||||
type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
isInstalled: boolean;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -23,15 +27,18 @@ const App = () => {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const {status} = x;
|
||||
const { status } = x;
|
||||
if (status === 400) {
|
||||
history.replace('/login');
|
||||
} else {
|
||||
const response: RefreshTokenResponse = await x.json();
|
||||
const {accessToken} = response;
|
||||
const { accessToken, isInstalled } = response;
|
||||
const claims: JWTToken = jwtDecode(accessToken);
|
||||
setUserID(claims.userId);
|
||||
setAccessToken(accessToken);
|
||||
if (!isInstalled) {
|
||||
history.replace('/install');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
@ -39,7 +46,7 @@ const App = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserIDContext.Provider value={{userID, setUserID}}>
|
||||
<UserIDContext.Provider value={{ userID, setUserID }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
|
@ -1,22 +1,22 @@
|
||||
import React, {useState, useEffect, useContext} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useHistory} from 'react-router';
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
import {setAccessToken} from 'shared/utils/accessToken';
|
||||
import { setAccessToken } from 'shared/utils/accessToken';
|
||||
|
||||
import Login from 'shared/components/Login';
|
||||
import {Container, LoginWrapper} from './Styles';
|
||||
import { Container, LoginWrapper } from './Styles';
|
||||
import UserIDContext from 'App/context';
|
||||
import JwtDecode from 'jwt-decode';
|
||||
|
||||
const Auth = () => {
|
||||
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
|
||||
const history = useHistory();
|
||||
const {setUserID} = useContext(UserIDContext);
|
||||
const { setUserID } = useContext(UserIDContext);
|
||||
const login = (
|
||||
data: LoginFormData,
|
||||
setComplete: (val: boolean) => void,
|
||||
setError: (field: string, eType: string, message: string) => void,
|
||||
setError: (name: 'username' | 'password', error: ErrorOption) => void,
|
||||
) => {
|
||||
fetch('/auth/login', {
|
||||
credentials: 'include',
|
||||
@ -28,12 +28,12 @@ const Auth = () => {
|
||||
}).then(async x => {
|
||||
if (x.status === 401) {
|
||||
setInvalidLoginAttempt(invalidLoginAttempt + 1);
|
||||
setError('username', 'invalid', 'Invalid username');
|
||||
setError('password', 'invalid', 'Invalid password');
|
||||
setError('username', { type: 'error', message: 'Invalid username' });
|
||||
setError('password', { type: 'error', message: 'Invalid password' });
|
||||
setComplete(true);
|
||||
} else {
|
||||
const response = await x.json();
|
||||
const {accessToken} = response;
|
||||
const { accessToken } = response;
|
||||
const claims: JWTToken = JwtDecode(accessToken);
|
||||
setUserID(claims.userId);
|
||||
setComplete(true);
|
||||
@ -49,7 +49,7 @@ const Auth = () => {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const {status} = x;
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
history.replace('/projects');
|
||||
}
|
||||
|
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 {MENU_TYPES} from 'shared/components/TopNavbar';
|
||||
import React, { useState, useRef, useContext, useEffect } from 'react';
|
||||
import { MENU_TYPES } from 'shared/components/TopNavbar';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import GlobalTopNavbar, {ProjectPopup} from 'App/TopNavbar';
|
||||
import styled, {css} from 'styled-components/macro';
|
||||
import {Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter} from 'shared/icons';
|
||||
import LabelManagerEditor from '../LabelManagerEditor'
|
||||
import {usePopup, Popup} from 'shared/components/PopupMenu';
|
||||
import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation} from 'react-router-dom';
|
||||
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
|
||||
import LabelManagerEditor from '../LabelManagerEditor';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
useSetProjectOwnerMutation,
|
||||
useUpdateProjectMemberRoleMutation,
|
||||
@ -61,7 +61,7 @@ const ProjectActions = styled.div`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ProjectAction = styled.div<{disabled?: boolean}>`
|
||||
const ProjectAction = styled.div<{ disabled?: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -103,18 +103,21 @@ const initialQuickCardEditorState: QuickCardEditorState = {
|
||||
};
|
||||
|
||||
type ProjectBoardProps = {
|
||||
onCardLabelClick: () => void;
|
||||
cardLabelVariant: CardLabelVariant;
|
||||
projectID: string;
|
||||
};
|
||||
const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
|
||||
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
|
||||
const [assignTask] = useAssignTaskMutation();
|
||||
const [unassignTask] = useUnassignTaskMutation();
|
||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
||||
const match = useRouteMatch();
|
||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||
const {showPopup, hidePopup} = usePopup();
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
|
||||
const {userID} = useContext(UserIDContext);
|
||||
const { userID } = useContext(UserIDContext);
|
||||
const [updateTaskGroupLocation] = useUpdateTaskGroupLocationMutation({});
|
||||
const history = useHistory();
|
||||
const [deleteTaskGroup] = useDeleteTaskGroupMutation({
|
||||
@ -128,7 +131,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
(taskGroup: TaskGroup) => taskGroup.id !== deletedTaskGroupData.data.deleteTaskGroup.taskGroup.id,
|
||||
);
|
||||
}),
|
||||
{projectId: projectID},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -140,13 +143,13 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const {taskGroups} = cache.findProject;
|
||||
const { taskGroups } = cache.findProject;
|
||||
const idx = taskGroups.findIndex(taskGroup => taskGroup.id === newTaskData.data.createTask.taskGroup.id);
|
||||
if (idx !== -1) {
|
||||
draftCache.findProject.taskGroups[idx].tasks.push({...newTaskData.data.createTask});
|
||||
draftCache.findProject.taskGroups[idx].tasks.push({ ...newTaskData.data.createTask });
|
||||
}
|
||||
}),
|
||||
{projectId: projectID},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -156,17 +159,18 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache => produce(cache, draftCache => {
|
||||
draftCache.findProject.taskGroups.push({...newTaskGroupData.data.createTaskGroup, tasks: []});
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
|
||||
}),
|
||||
{projectId: projectID},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [updateTaskGroupName] = useUpdateTaskGroupNameMutation({});
|
||||
const {loading, data} = useFindProjectQuery({
|
||||
variables: {projectId: projectID},
|
||||
const { loading, data } = useFindProjectQuery({
|
||||
variables: { projectId: projectID },
|
||||
});
|
||||
|
||||
const [updateTaskDueDate] = useUpdateTaskDueDateMutation();
|
||||
@ -178,9 +182,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const {previousTaskGroupID, task} = newTask.data.updateTaskLocation;
|
||||
const { previousTaskGroupID, task } = newTask.data.updateTaskLocation;
|
||||
if (previousTaskGroupID !== task.taskGroup.id) {
|
||||
const {taskGroups} = cache.findProject;
|
||||
const { taskGroups } = cache.findProject;
|
||||
const oldTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === previousTaskGroupID);
|
||||
const newTaskGroupIdx = taskGroups.findIndex((t: TaskGroup) => t.id === task.taskGroup.id);
|
||||
if (oldTaskGroupIdx !== -1 && newTaskGroupIdx !== -1) {
|
||||
@ -189,12 +193,12 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
);
|
||||
draftCache.findProject.taskGroups[newTaskGroupIdx].tasks = [
|
||||
...taskGroups[newTaskGroupIdx].tasks,
|
||||
{...task},
|
||||
{ ...task },
|
||||
];
|
||||
}
|
||||
}
|
||||
}),
|
||||
{projectId: projectID},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -222,7 +226,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
|
||||
console.log(`position ${position}`);
|
||||
createTask({
|
||||
variables: {taskGroupID, name, position},
|
||||
variables: { taskGroupID, name, position },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
createTask: {
|
||||
@ -258,7 +262,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
if (lastColumn) {
|
||||
position = lastColumn.position * 2 + 1;
|
||||
}
|
||||
createTaskGroup({variables: {projectID, name: listName, position}});
|
||||
createTaskGroup({ variables: { projectID, name: listName, position } });
|
||||
}
|
||||
};
|
||||
|
||||
@ -341,6 +345,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
onTaskClick={task => {
|
||||
history.push(`${match.url}/c/${task.id}`);
|
||||
}}
|
||||
onCardLabelClick={onCardLabelClick}
|
||||
cardLabelVariant={cardLabelVariant}
|
||||
onTaskDrop={(droppedTask, previousTaskGroupID) => {
|
||||
updateTaskLocation({
|
||||
variables: {
|
||||
@ -370,7 +376,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
}}
|
||||
onTaskGroupDrop={droppedTaskGroup => {
|
||||
updateTaskGroupLocation({
|
||||
variables: {taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position},
|
||||
variables: { taskGroupID: droppedTaskGroup.id, position: droppedTaskGroup.position },
|
||||
optimisticResponse: {
|
||||
__typename: 'Mutation',
|
||||
updateTaskGroupLocation: {
|
||||
@ -400,7 +406,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
}
|
||||
}}
|
||||
onChangeTaskGroupName={(taskGroupID, name) => {
|
||||
updateTaskGroupName({variables: {taskGroupID, name}});
|
||||
updateTaskGroupName({ variables: { taskGroupID, name } });
|
||||
}}
|
||||
onQuickEditorOpen={onQuickEditorOpen}
|
||||
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
|
||||
@ -410,7 +416,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
<ListActions
|
||||
taskGroupID={taskGroupID}
|
||||
onArchiveTaskGroup={tgID => {
|
||||
deleteTaskGroup({variables: {taskGroupID: tgID}});
|
||||
deleteTaskGroup({ variables: { taskGroupID: tgID } });
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
@ -423,7 +429,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
task={currentQuickTask}
|
||||
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
|
||||
onEditCard={(_taskGroupID: string, taskID: string, cardName: string) => {
|
||||
updateTaskName({variables: {taskID, name: cardName}});
|
||||
updateTaskName({ variables: { taskID, name: cardName } });
|
||||
}}
|
||||
onOpenMembersPopup={($targetRef, task) => {
|
||||
showPopup(
|
||||
@ -434,9 +440,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
activeMembers={task.assigned ?? []}
|
||||
onMemberChange={(member, isActive) => {
|
||||
if (isActive) {
|
||||
assignTask({variables: {taskID: task.id, userID: userID ?? ''}});
|
||||
assignTask({ variables: { taskID: task.id, userID: userID ?? '' } });
|
||||
} else {
|
||||
unassignTask({variables: {taskID: task.id, userID: userID ?? ''}});
|
||||
unassignTask({ variables: { taskID: task.id, userID: userID ?? '' } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -464,7 +470,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
$targetRef,
|
||||
<LabelManagerEditor
|
||||
onLabelToggle={labelID => {
|
||||
toggleTaskLabel({variables: {taskID: task.id, projectLabelID: labelID}});
|
||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||
}}
|
||||
labelColors={data.labelColors}
|
||||
labels={labelsRef}
|
||||
@ -475,7 +481,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
}}
|
||||
onArchiveCard={(_listId: string, cardId: string) =>
|
||||
deleteTask({
|
||||
variables: {taskID: cardId},
|
||||
variables: { taskID: cardId },
|
||||
update: client => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
@ -487,7 +493,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
tasks: taskGroup.tasks.filter(t => t.id !== cardId),
|
||||
}));
|
||||
}),
|
||||
{projectId: projectID},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
})
|
||||
@ -499,11 +505,11 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
<DueDateManager
|
||||
task={task}
|
||||
onRemoveDueDate={t => {
|
||||
updateTaskDueDate({variables: {taskID: t.id, dueDate: null}});
|
||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: null } });
|
||||
hidePopup();
|
||||
}}
|
||||
onDueDateChange={(t, newDueDate) => {
|
||||
updateTaskDueDate({variables: {taskID: t.id, dueDate: newDueDate}});
|
||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||
hidePopup();
|
||||
}}
|
||||
onCancel={() => {}}
|
||||
@ -512,7 +518,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
);
|
||||
}}
|
||||
onToggleComplete={task => {
|
||||
setTaskComplete({variables: {taskID: task.id, complete: !task.complete}});
|
||||
setTaskComplete({ variables: { taskID: task.id, complete: !task.complete } });
|
||||
}}
|
||||
target={quickCardEditor.target}
|
||||
/>
|
||||
|
@ -92,7 +92,7 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
|
||||
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
|
||||
<CreateChecklistInput
|
||||
floatingLabel
|
||||
value="Checklist"
|
||||
defaultValue="Checklist"
|
||||
width="100%"
|
||||
label="Name"
|
||||
id="name"
|
||||
@ -194,8 +194,10 @@ const Details: React.FC<DetailsProps> = ({
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
const { checklists } = cache.findTask;
|
||||
console.log(deleteData)
|
||||
draftCache.findTask.checklists = checklists.filter(c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id);
|
||||
console.log(deleteData);
|
||||
draftCache.findTask.checklists = checklists.filter(
|
||||
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
|
||||
);
|
||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
__typename: 'ChecklistBadge',
|
||||
@ -234,9 +236,11 @@ const Details: React.FC<DetailsProps> = ({
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
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) {
|
||||
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);
|
||||
draftCache.findTask.badges.checklist = {
|
||||
@ -372,9 +376,9 @@ const Details: React.FC<DetailsProps> = ({
|
||||
__typename: 'TaskChecklistItem',
|
||||
id: itemID,
|
||||
taskChecklistID: checklistID,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
onToggleChecklistItem={(itemID, complete) => {
|
||||
@ -398,7 +402,7 @@ const Details: React.FC<DetailsProps> = ({
|
||||
if (member) {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title={null} onClose={() => { }} tab={0}>
|
||||
<Popup title={null} onClose={() => {}} tab={0}>
|
||||
<MiniProfile
|
||||
user={member}
|
||||
bio="None"
|
||||
@ -413,7 +417,7 @@ const Details: React.FC<DetailsProps> = ({
|
||||
onOpenAddMemberPopup={(task, $targetRef) => {
|
||||
showPopup(
|
||||
$targetRef,
|
||||
<Popup title="Members" tab={0} onClose={() => { }}>
|
||||
<Popup title="Members" tab={0} onClose={() => {}}>
|
||||
<MemberManager
|
||||
availableMembers={availableMembers}
|
||||
activeMembers={data.findTask.assigned}
|
||||
@ -500,7 +504,7 @@ const Details: React.FC<DetailsProps> = ({
|
||||
updateTaskDueDate({ variables: { taskID: t.id, dueDate: newDueDate } });
|
||||
hidePopup();
|
||||
}}
|
||||
onCancel={() => { }}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
</Popup>,
|
||||
);
|
||||
|
@ -1,11 +1,19 @@
|
||||
// LOC830
|
||||
import React, {useState, useRef, useEffect, useContext} from 'react';
|
||||
import React, { useState, useRef, useEffect, useContext } from 'react';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import GlobalTopNavbar, {ProjectPopup} from 'App/TopNavbar';
|
||||
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
||||
import styled from 'styled-components/macro';
|
||||
import {usePopup, Popup} from 'shared/components/PopupMenu';
|
||||
import LabelManagerEditor from './LabelManagerEditor'
|
||||
import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation, Redirect} from 'react-router-dom';
|
||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||
import LabelManagerEditor from './LabelManagerEditor';
|
||||
import {
|
||||
useParams,
|
||||
Route,
|
||||
useRouteMatch,
|
||||
useHistory,
|
||||
RouteComponentProps,
|
||||
useLocation,
|
||||
Redirect,
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
useSetProjectOwnerMutation,
|
||||
useUpdateProjectMemberRoleMutation,
|
||||
@ -29,8 +37,20 @@ import produce from 'immer';
|
||||
import UserIDContext from 'App/context';
|
||||
import Input from 'shared/components/Input';
|
||||
import Member from 'shared/components/Member';
|
||||
import Board from './Board'
|
||||
import Details from './Details'
|
||||
import Board from './Board';
|
||||
import Details from './Details';
|
||||
|
||||
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
|
||||
|
||||
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
|
||||
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem(localStorageKey, value);
|
||||
}, [value]);
|
||||
|
||||
return [value, setValue];
|
||||
};
|
||||
|
||||
const SearchInput = styled(Input)`
|
||||
margin: 0;
|
||||
@ -55,7 +75,7 @@ type UserManagementPopupProps = {
|
||||
onAddProjectMember: (userID: string) => void;
|
||||
};
|
||||
|
||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({users, projectMembers, onAddProjectMember}) => {
|
||||
const UserManagementPopup: React.FC<UserManagementPopupProps> = ({ users, projectMembers, onAddProjectMember }) => {
|
||||
return (
|
||||
<Popup tab={0} title="Invite a user">
|
||||
<SearchInput width="100%" variant="alternate" placeholder="Email address or name" name="search" />
|
||||
@ -99,7 +119,7 @@ const initialQuickCardEditorState: QuickCardEditorState = {
|
||||
};
|
||||
|
||||
const Project = () => {
|
||||
const {projectID} = useParams<ProjectParams>();
|
||||
const { projectID } = useParams<ProjectParams>();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
|
||||
@ -110,14 +130,16 @@ const Project = () => {
|
||||
console.log(taskLabelsRef.current);
|
||||
},
|
||||
});
|
||||
|
||||
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
|
||||
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
||||
|
||||
const [deleteTask] = useDeleteTaskMutation();
|
||||
|
||||
const [updateTaskName] = useUpdateTaskNameMutation();
|
||||
|
||||
const {loading, data} = useFindProjectQuery({
|
||||
variables: {projectId: projectID},
|
||||
const { loading, data } = useFindProjectQuery({
|
||||
variables: { projectId: projectID },
|
||||
});
|
||||
|
||||
const [updateProjectName] = useUpdateProjectNameMutation({
|
||||
@ -129,7 +151,7 @@ const Project = () => {
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.name = newName.data.updateProjectName.name;
|
||||
}),
|
||||
{projectId: projectID},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -141,9 +163,9 @@ const Project = () => {
|
||||
FindProjectDocument,
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.members.push({...response.data.createProjectMember.member});
|
||||
draftCache.findProject.members.push({ ...response.data.createProjectMember.member });
|
||||
}),
|
||||
{projectId: projectID},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
@ -159,15 +181,15 @@ const Project = () => {
|
||||
m => m.id !== response.data.deleteProjectMember.member.id,
|
||||
);
|
||||
}),
|
||||
{projectId: projectID},
|
||||
{ projectId: projectID },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const {userID} = useContext(UserIDContext);
|
||||
const { userID } = useContext(UserIDContext);
|
||||
const location = useLocation();
|
||||
|
||||
const {showPopup, hidePopup} = usePopup();
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||
@ -192,25 +214,25 @@ const Project = () => {
|
||||
<>
|
||||
<GlobalTopNavbar
|
||||
onChangeRole={(userID, roleCode) => {
|
||||
updateProjectMemberRole({variables: {userID, roleCode, projectID}});
|
||||
updateProjectMemberRole({ variables: { userID, roleCode, projectID } });
|
||||
}}
|
||||
onChangeProjectOwner={uid => {
|
||||
setProjectOwner({variables: {ownerID: uid, projectID}});
|
||||
setProjectOwner({ variables: { ownerID: uid, projectID } });
|
||||
hidePopup();
|
||||
}}
|
||||
onRemoveFromBoard={userID => {
|
||||
deleteProjectMember({variables: {userID, projectID}});
|
||||
deleteProjectMember({ variables: { userID, projectID } });
|
||||
hidePopup();
|
||||
}}
|
||||
onSaveProjectName={projectName => {
|
||||
updateProjectName({variables: {projectID, name: projectName}});
|
||||
updateProjectName({ variables: { projectID, name: projectName } });
|
||||
}}
|
||||
onInviteUser={$target => {
|
||||
showPopup(
|
||||
$target,
|
||||
<UserManagementPopup
|
||||
onAddProjectMember={userID => {
|
||||
createProjectMember({variables: {userID, projectID}});
|
||||
createProjectMember({ variables: { userID, projectID } });
|
||||
}}
|
||||
users={data.users}
|
||||
projectMembers={data.findProject.members}
|
||||
@ -218,23 +240,24 @@ const Project = () => {
|
||||
);
|
||||
}}
|
||||
popupContent={<ProjectPopup history={history} name={data.findProject.name} projectID={projectID} />}
|
||||
menuType={[{name: 'Board', link: location.pathname}]}
|
||||
menuType={[{ name: 'Board', link: location.pathname }]}
|
||||
currentTab={0}
|
||||
projectMembers={data.findProject.members}
|
||||
projectID={projectID}
|
||||
name={data.findProject.name}
|
||||
/>
|
||||
<Route
|
||||
path={`${match.path}`}
|
||||
exact
|
||||
render={() => (
|
||||
<Redirect to={`${match.url}/board`} />
|
||||
)}
|
||||
/>
|
||||
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
|
||||
<Route
|
||||
path={`${match.path}/board`}
|
||||
render={() => (
|
||||
<Board projectID={projectID} />
|
||||
<Board
|
||||
cardLabelVariant={value === 'small' ? 'large' : 'small'}
|
||||
onCardLabelClick={() => {
|
||||
const variant = value === 'small' ? 'large' : 'small';
|
||||
setValue(() => variant);
|
||||
}}
|
||||
projectID={projectID}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
@ -246,13 +269,13 @@ const Project = () => {
|
||||
projectURL={`${match.url}/board`}
|
||||
taskID={routeProps.match.params.taskID}
|
||||
onTaskNameChange={(updatedTask, newName) => {
|
||||
updateTaskName({variables: {taskID: updatedTask.id, name: newName}});
|
||||
updateTaskName({ variables: { taskID: updatedTask.id, name: newName } });
|
||||
}}
|
||||
onTaskDescriptionChange={(updatedTask, newDescription) => {
|
||||
updateTaskDescription({variables: {taskID: updatedTask.id, description: newDescription}});
|
||||
updateTaskDescription({ variables: { taskID: updatedTask.id, description: newDescription } });
|
||||
}}
|
||||
onDeleteTask={deletedTask => {
|
||||
deleteTask({variables: {taskID: deletedTask.id}});
|
||||
deleteTask({ variables: { taskID: deletedTask.id } });
|
||||
}}
|
||||
onOpenAddLabelPopup={(task, $targetRef) => {
|
||||
taskLabelsRef.current = task.labels;
|
||||
@ -260,7 +283,7 @@ const Project = () => {
|
||||
$targetRef,
|
||||
<LabelManagerEditor
|
||||
onLabelToggle={labelID => {
|
||||
toggleTaskLabel({variables: {taskID: task.id, projectLabelID: labelID}});
|
||||
toggleTaskLabel({ variables: { taskID: task.id, projectLabelID: labelID } });
|
||||
}}
|
||||
labelColors={data.labelColors}
|
||||
labels={labelsRef}
|
||||
|
30
frontend/src/citadel.d.ts
vendored
30
frontend/src/citadel.d.ts
vendored
@ -47,6 +47,7 @@ type TaskUser = {
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
isInstalled: boolean;
|
||||
};
|
||||
|
||||
type LoginFormData = {
|
||||
@ -54,16 +55,41 @@ type LoginFormData = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
type RegisterFormData = {
|
||||
username: string;
|
||||
fullname: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirm: string;
|
||||
initials: string;
|
||||
};
|
||||
|
||||
type DueDateFormData = {
|
||||
endDate: string;
|
||||
endTime: string;
|
||||
};
|
||||
type ErrorOption =
|
||||
| {
|
||||
types: MultipleFieldErrors;
|
||||
}
|
||||
| {
|
||||
message?: Message;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type RegisterProps = {
|
||||
onSubmit: (
|
||||
data: RegisterFormData,
|
||||
setComplete: (val: boolean) => void,
|
||||
setError: (name: 'username' | 'email' | 'password' | 'password_confirm' | 'initials', error: ErrorOption) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type LoginProps = {
|
||||
onSubmit: (
|
||||
data: LoginFormData,
|
||||
setComplete: (val: boolean) => void,
|
||||
setError: (field: string, eType: string, message: string) => void,
|
||||
setError: (name: 'username' | 'password', error: ErrorOption) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
@ -85,3 +111,5 @@ type ElementBounds = {
|
||||
size: ElementSize;
|
||||
position: ElementPosition;
|
||||
};
|
||||
|
||||
type CardLabelVariant = 'large' | 'small';
|
||||
|
@ -2,13 +2,13 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
||||
import {ApolloProvider} from '@apollo/react-hooks';
|
||||
import {ApolloClient} from 'apollo-client';
|
||||
import {InMemoryCache} from 'apollo-cache-inmemory';
|
||||
import {HttpLink} from 'apollo-link-http';
|
||||
import {onError} from 'apollo-link-error';
|
||||
import {ApolloLink, Observable, fromPromise} from 'apollo-link';
|
||||
import {getAccessToken, getNewToken, setAccessToken} from 'shared/utils/accessToken';
|
||||
import { ApolloProvider } from '@apollo/react-hooks';
|
||||
import { ApolloClient } from 'apollo-client';
|
||||
import { InMemoryCache } from 'apollo-cache-inmemory';
|
||||
import { HttpLink } from 'apollo-link-http';
|
||||
import { onError } from 'apollo-link-error';
|
||||
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
|
||||
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
|
||||
import App from './App';
|
||||
|
||||
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
||||
@ -18,7 +18,7 @@ let isRefreshing = false;
|
||||
let pendingRequests: any = [];
|
||||
|
||||
const refreshAuthLogic = (failedRequest: any) =>
|
||||
axios.post('/auth/refresh_token', {}, {withCredentials: true}).then(tokenRefreshResponse => {
|
||||
axios.post('/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
|
||||
setAccessToken(tokenRefreshResponse.data.accessToken);
|
||||
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
|
||||
return Promise.resolve();
|
||||
@ -43,7 +43,7 @@ const setRefreshing = (newVal: boolean) => {
|
||||
isRefreshing = newVal;
|
||||
};
|
||||
|
||||
const errorLink = onError(({graphQLErrors, networkError, operation, forward}) => {
|
||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||
if (graphQLErrors) {
|
||||
for (const err of graphQLErrors) {
|
||||
if (err.extensions && err.extensions.code) {
|
||||
@ -118,9 +118,9 @@ const requestLink = new ApolloLink(
|
||||
|
||||
const client = new ApolloClient({
|
||||
link: ApolloLink.from([
|
||||
onError(({graphQLErrors, networkError}) => {
|
||||
onError(({ graphQLErrors, networkError }) => {
|
||||
if (graphQLErrors) {
|
||||
graphQLErrors.forEach(({message, locations, path}) =>
|
||||
graphQLErrors.forEach(({ message, locations, path }) =>
|
||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
|
||||
);
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||
onChangeRole,
|
||||
}) => {
|
||||
const { hidePopup, setTab } = usePopup();
|
||||
const [userPass, setUserPass] = useState({ pass: "", passConfirm: "" });
|
||||
const [userPass, setUserPass] = useState({ pass: '', passConfirm: '' });
|
||||
return (
|
||||
<>
|
||||
<Popup title={null} tab={0}>
|
||||
@ -137,9 +137,13 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
||||
</MiniProfileActionItem>
|
||||
)}
|
||||
<MiniProfileActionItem onClick={() => {
|
||||
setTab(3)
|
||||
}}>Reset password...</MiniProfileActionItem>
|
||||
<MiniProfileActionItem
|
||||
onClick={() => {
|
||||
setTab(3);
|
||||
}}
|
||||
>
|
||||
Reset password...
|
||||
</MiniProfileActionItem>
|
||||
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
|
||||
</MiniProfileActionWrapper>
|
||||
</MiniProfileActions>
|
||||
@ -214,23 +218,39 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
|
||||
<Content>
|
||||
<DeleteDescription>
|
||||
You can either set the user's new password directly or send the user an email allowing them to reset their own password.
|
||||
You can either set the user's new password directly or send the user an email allowing them to reset their
|
||||
own password.
|
||||
</DeleteDescription>
|
||||
<UserPassBar>
|
||||
<UserPassButton onClick={() => setTab(4)} color="warning">Set password...</UserPassButton>
|
||||
<UserPassButton color="warning" variant="outline">Send reset link</UserPassButton>
|
||||
<UserPassButton onClick={() => setTab(4)} color="warning">
|
||||
Set password...
|
||||
</UserPassButton>
|
||||
<UserPassButton color="warning" variant="outline">
|
||||
Send reset link
|
||||
</UserPassButton>
|
||||
</UserPassBar>
|
||||
</Content>
|
||||
</Popup>
|
||||
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
||||
<Content>
|
||||
<NewUserPassInput onChange={e => setUserPass({ pass: e.currentTarget.value, passConfirm: userPass.passConfirm })} value={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
||||
<NewUserPassInput onChange={e => setUserPass({ passConfirm: e.currentTarget.value, pass: userPass.pass })} value={userPass.passConfirm} width="100%" variant="alternate" placeholder="New password (confirm)" />
|
||||
<UserPassConfirmButton disabled={userPass.pass === "" || userPass.passConfirm === ""} onClick={() => {
|
||||
<NewUserPassInput defaultValue={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
||||
<NewUserPassInput
|
||||
defaultValue={userPass.passConfirm}
|
||||
width="100%"
|
||||
variant="alternate"
|
||||
placeholder="New password (confirm)"
|
||||
/>
|
||||
<UserPassConfirmButton
|
||||
disabled={userPass.pass === '' || userPass.passConfirm === ''}
|
||||
onClick={() => {
|
||||
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
|
||||
updateUserPassword(user, userPass.pass)
|
||||
updateUserPassword(user, userPass.pass);
|
||||
}
|
||||
}} color="danger">Set password</UserPassConfirmButton>
|
||||
}}
|
||||
color="danger"
|
||||
>
|
||||
Set password
|
||||
</UserPassConfirmButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
<Popup title="Remove user" onClose={() => hidePopup()} tab={5}>
|
||||
@ -238,36 +258,36 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||
<DeleteDescription>
|
||||
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
||||
</DeleteDescription>
|
||||
<DeleteDescription>
|
||||
The user is the owner of 3 projects & 2 teams.
|
||||
</DeleteDescription>
|
||||
<UserSelect onChange={() => { }} value={null} options={[{ label: 'Jordan Knott', value: "jordanknott" }]} />
|
||||
<UserPassConfirmButton onClick={() => { }} color="danger">Set password</UserPassConfirmButton>
|
||||
<DeleteDescription>The user is the owner of 3 projects & 2 teams.</DeleteDescription>
|
||||
<UserSelect onChange={() => {}} value={null} options={[{ label: 'Jordan Knott', value: 'jordanknott' }]} />
|
||||
<UserPassConfirmButton onClick={() => {}} color="danger">
|
||||
Set password
|
||||
</UserPassConfirmButton>
|
||||
</Content>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const UserSelect = styled(Select)`
|
||||
margin: 8px 0;
|
||||
padding: 8px 0;
|
||||
`
|
||||
margin: 8px 0;
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
const NewUserPassInput = styled(Input)`
|
||||
margin: 8px 0;
|
||||
`
|
||||
margin: 8px 0;
|
||||
`;
|
||||
const InviteMemberButton = styled(Button)`
|
||||
padding: 7px 12px;
|
||||
`;
|
||||
|
||||
const UserPassBar = styled.div`
|
||||
display: flex;
|
||||
padding-top: 8px;
|
||||
`
|
||||
display: flex;
|
||||
padding-top: 8px;
|
||||
`;
|
||||
const UserPassConfirmButton = styled(Button)`
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
`
|
||||
`;
|
||||
|
||||
const UserPassButton = styled(Button)`
|
||||
width: 50%;
|
||||
@ -275,7 +295,7 @@ const UserPassButton = styled(Button)`
|
||||
& ~ & {
|
||||
margin-left: 6px;
|
||||
}
|
||||
`
|
||||
`;
|
||||
|
||||
const MemberItemOptions = styled.div``;
|
||||
|
||||
@ -434,7 +454,7 @@ const ActionButton: React.FC<ActionButtonProps> = ({ onClick, children }) => {
|
||||
const ActionButtons = (params: any) => {
|
||||
return (
|
||||
<>
|
||||
<ActionButton onClick={() => { }}>
|
||||
<ActionButton onClick={() => {}}>
|
||||
<EditUserIcon width={16} height={16} />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={$target => params.onDeleteUser($target, params.value)}>
|
||||
@ -639,7 +659,14 @@ type AdminProps = {
|
||||
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 =
|
||||
'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);
|
||||
@ -647,7 +674,7 @@ const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPasswo
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const $tabNav = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [updateUserRole] = useUpdateUserRoleMutation()
|
||||
const [updateUserRole] = useUpdateUserRoleMutation();
|
||||
return (
|
||||
<Container>
|
||||
<TabNav ref={$tabNav}>
|
||||
@ -692,7 +719,7 @@ const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPasswo
|
||||
<MemberList>
|
||||
{users.map(member => (
|
||||
<MemberListItem>
|
||||
<MemberProfile showRoleIcons size={32} onMemberProfile={() => { }} member={member} />
|
||||
<MemberProfile showRoleIcons size={32} onMemberProfile={() => {}} member={member} />
|
||||
<MemberListItemDetails>
|
||||
<MemberItemName>{member.fullName}</MemberItemName>
|
||||
<MemberItemUsername>{`@${member.username}`}</MemberItemUsername>
|
||||
@ -708,11 +735,11 @@ const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPasswo
|
||||
user={member}
|
||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||
updateUserPassword={(user, password) => {
|
||||
onUpdateUserPassword(user, password)
|
||||
onUpdateUserPassword(user, password);
|
||||
}}
|
||||
canChangeRole={member.role && member.role.code !== 'owner'}
|
||||
onChangeRole={roleCode => {
|
||||
updateUserRole({ variables: { userID: member.id, roleCode } })
|
||||
updateUserRole({ variables: { userID: member.id, roleCode } });
|
||||
}}
|
||||
onRemoveFromTeam={
|
||||
member.role && member.role.code === 'owner'
|
||||
|
@ -1,9 +1,9 @@
|
||||
import styled, {css} from 'styled-components';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {mixin} from 'shared/utils/styles';
|
||||
import styled, { css, keyframes } from 'styled-components';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
import TextareaAutosize from 'react-autosize-textarea';
|
||||
import {CheckCircle} from 'shared/icons';
|
||||
import {RefObject} from 'react';
|
||||
import { CheckCircle } from 'shared/icons';
|
||||
import { RefObject } from 'react';
|
||||
|
||||
export const ClockIcon = styled(FontAwesomeIcon)``;
|
||||
|
||||
@ -57,7 +57,7 @@ export const DescriptionBadge = styled(ListCardBadge)`
|
||||
padding-right: 6px;
|
||||
`;
|
||||
|
||||
export const DueDateCardBadge = styled(ListCardBadge) <{isPastDue: boolean}>`
|
||||
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
|
||||
font-size: 12px;
|
||||
${props =>
|
||||
props.isPastDue &&
|
||||
@ -76,7 +76,7 @@ export const ListCardBadgeText = styled.span`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const ListCardContainer = styled.div<{isActive: boolean; editable: boolean}>`
|
||||
export const ListCardContainer = styled.div<{ isActive: boolean; editable: boolean }>`
|
||||
max-width: 256px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 3px;
|
||||
@ -93,7 +93,7 @@ export const ListCardInnerContainer = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
export const ListCardDetails = styled.div<{complete: boolean}>`
|
||||
export const ListCardDetails = styled.div<{ complete: boolean }>`
|
||||
overflow: hidden;
|
||||
padding: 6px 8px 2px;
|
||||
position: relative;
|
||||
@ -102,28 +102,93 @@ export const ListCardDetails = styled.div<{complete: boolean}>`
|
||||
${props => props.complete && 'opacity: 0.6;'}
|
||||
`;
|
||||
|
||||
export const ListCardLabels = styled.div`
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
const labelVariantExpandAnimation = keyframes`
|
||||
0% {min-width: 40px; height: 8px;}
|
||||
50% {min-width: 56px; height: 8px;}
|
||||
100% {min-width: 56px; height: 16px;}
|
||||
`;
|
||||
|
||||
export const ListCardLabel = styled.span`
|
||||
height: 16px;
|
||||
const labelTextVariantExpandAnimation = keyframes`
|
||||
0% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||
100% {transform: scale(1); visibility: visible; pointer-events: all;}
|
||||
`;
|
||||
|
||||
const labelVariantShrinkAnimation = keyframes`
|
||||
0% {min-width: 56px; height: 16px;}
|
||||
50% {min-width: 56px; height: 8px;}
|
||||
100% {min-width: 40px; height: 8px;}
|
||||
`;
|
||||
|
||||
const labelTextVariantShrinkAnimation = keyframes`
|
||||
0% {transform: scale(1); visibility: visible; pointer-events: all;}
|
||||
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||
100% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||
`;
|
||||
export const ListCardLabelText = styled.span`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
`;
|
||||
|
||||
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
|
||||
${props =>
|
||||
props.variant === 'small'
|
||||
? css`
|
||||
height: 8px;
|
||||
min-width: 40px;
|
||||
& ${ListCardLabelText} {
|
||||
transform: scale(0);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
: css`
|
||||
height: 16px;
|
||||
min-width: 56px;
|
||||
`}
|
||||
|
||||
padding: 0 8px;
|
||||
max-width: 198px;
|
||||
float: left;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin: 0 4px 4px 0;
|
||||
width: auto;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: block;
|
||||
display: flex;
|
||||
position: relative;
|
||||
background-color: ${props => props.color};
|
||||
`;
|
||||
|
||||
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
${props =>
|
||||
props.toggleLabels &&
|
||||
props.toggleDirection === 'expand' &&
|
||||
css`
|
||||
& ${ListCardLabel} {
|
||||
animation: ${labelVariantExpandAnimation} 0.45s ease-out;
|
||||
}
|
||||
& ${ListCardLabelText} {
|
||||
animation: ${labelTextVariantExpandAnimation} 0.45s ease-out;
|
||||
}
|
||||
`}
|
||||
${props =>
|
||||
props.toggleLabels &&
|
||||
props.toggleDirection === 'shrink' &&
|
||||
css`
|
||||
& ${ListCardLabel} {
|
||||
animation: ${labelVariantShrinkAnimation} 0.45s ease-out;
|
||||
}
|
||||
& ${ListCardLabelText} {
|
||||
animation: ${labelTextVariantShrinkAnimation} 0.45s ease-out;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
export const ListCardOperation = styled.span`
|
||||
display: flex;
|
||||
align-content: center;
|
||||
@ -136,7 +201,7 @@ export const ListCardOperation = styled.span`
|
||||
top: 2px;
|
||||
z-index: 100;
|
||||
&:hover {
|
||||
background-color: ${props => mixin.darken('#262c49', .25)};
|
||||
background-color: ${props => mixin.darken('#262c49', 0.25)};
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, {useState, useRef, useEffect} from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
import {faPencilAlt, faList} from '@fortawesome/free-solid-svg-icons';
|
||||
import {faClock, faCheckSquare, faEye} from '@fortawesome/free-regular-svg-icons';
|
||||
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
|
||||
import {
|
||||
EditorTextarea,
|
||||
EditorContent,
|
||||
@ -18,6 +18,7 @@ import {
|
||||
ClockIcon,
|
||||
ListCardLabels,
|
||||
ListCardLabel,
|
||||
ListCardLabelText,
|
||||
ListCardOperation,
|
||||
CardTitle,
|
||||
CardMembers,
|
||||
@ -47,10 +48,12 @@ type Props = {
|
||||
watched?: boolean;
|
||||
wrapperProps?: any;
|
||||
members?: Array<TaskUser> | null;
|
||||
onCardLabelClick?: () => void;
|
||||
onCardMemberClick?: OnCardMemberClick;
|
||||
editable?: boolean;
|
||||
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
|
||||
onCardTitleChange?: (name: string) => void;
|
||||
labelVariant?: CardLabelVariant;
|
||||
};
|
||||
|
||||
const Card = React.forwardRef(
|
||||
@ -69,14 +72,20 @@ const Card = React.forwardRef(
|
||||
checklists,
|
||||
watched,
|
||||
members,
|
||||
labelVariant,
|
||||
onCardMemberClick,
|
||||
editable,
|
||||
onCardLabelClick,
|
||||
onEditCard,
|
||||
onCardTitleChange,
|
||||
}: Props,
|
||||
$cardRef: any,
|
||||
) => {
|
||||
const [currentCardTitle, setCardTitle] = useState(title);
|
||||
const [toggleLabels, setToggleLabels] = useState(false);
|
||||
const [toggleDirection, setToggleDirection] = useState<'shrink' | 'expand'>(
|
||||
labelVariant === 'large' ? 'shrink' : 'expand',
|
||||
);
|
||||
const $editorRef: any = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
@ -132,21 +141,39 @@ const Card = React.forwardRef(
|
||||
>
|
||||
<ListCardInnerContainer ref={$innerCardRef}>
|
||||
{isActive && (
|
||||
<ListCardOperation onClick={e => {
|
||||
<ListCardOperation
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (onContextMenu) {
|
||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
||||
}
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
||||
</ListCardOperation>
|
||||
)}
|
||||
<ListCardDetails complete={complete ?? false}>
|
||||
<ListCardLabels>
|
||||
<ListCardLabels
|
||||
toggleLabels={toggleLabels}
|
||||
toggleDirection={toggleDirection}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (onCardLabelClick) {
|
||||
setToggleLabels(true);
|
||||
setToggleDirection(labelVariant === 'large' ? 'shrink' : 'expand');
|
||||
onCardLabelClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{labels &&
|
||||
labels.map(label => (
|
||||
<ListCardLabel color={label.labelColor.colorHex} key={label.id}>
|
||||
{label.name}
|
||||
<ListCardLabel
|
||||
onAnimationEnd={() => setToggleLabels(false)}
|
||||
variant={labelVariant ?? 'large'}
|
||||
color={label.labelColor.colorHex}
|
||||
key={label.id}
|
||||
>
|
||||
<ListCardLabelText>{label.name}</ListCardLabelText>
|
||||
</ListCardLabel>
|
||||
))}
|
||||
</ListCardLabels>
|
||||
|
@ -17,7 +17,7 @@ import {
|
||||
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { getYear, getMonth } from 'date-fns';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
|
||||
type DueDateManagerProps = {
|
||||
task: Task;
|
||||
@ -147,7 +147,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
const { register, handleSubmit, errors, setValue, setError, formState } = useForm<DueDateFormData>();
|
||||
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
|
||||
const saveDueDate = (data: any) => {
|
||||
console.log(data);
|
||||
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
|
||||
@ -155,27 +155,16 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
||||
onDueDateChange(task, newDate.toDate());
|
||||
}
|
||||
};
|
||||
console.log(errors);
|
||||
register({ name: 'endTime' }, { required: 'End time is required' });
|
||||
useEffect(() => {
|
||||
setValue('endTime', now.format('h:mm A'));
|
||||
}, []);
|
||||
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
|
||||
return (
|
||||
<DueDateInput
|
||||
id="endTime"
|
||||
name="endTime"
|
||||
ref={$ref}
|
||||
onChange={e => {
|
||||
console.log(`onCahnge ${e.currentTarget.value}`);
|
||||
setTextEndTime(e.currentTarget.value);
|
||||
setValue('endTime', e.currentTarget.value);
|
||||
}}
|
||||
width="100%"
|
||||
variant="alternate"
|
||||
label="Date"
|
||||
onClick={onClick}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -190,24 +179,21 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
||||
width="100%"
|
||||
variant="alternate"
|
||||
label="Date"
|
||||
onChange={e => {
|
||||
setTextStartDate(e.currentTarget.value);
|
||||
}}
|
||||
value={textStartDate}
|
||||
defaultValue={textStartDate}
|
||||
ref={register({
|
||||
required: 'End date is required.',
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Controller
|
||||
control={control}
|
||||
name="endTime"
|
||||
render={({ onChange, onBlur, value }) => (
|
||||
<DatePicker
|
||||
selected={endTime}
|
||||
onChange={date => {
|
||||
const changedDate = moment(date ?? new Date());
|
||||
console.log(`changed ${date}`);
|
||||
setEndTime(changedDate.toDate());
|
||||
setValue('endTime', changedDate.format('h:mm A'));
|
||||
}}
|
||||
onChange={onChange}
|
||||
selected={value}
|
||||
onBlur={onBlur}
|
||||
showTimeSelect
|
||||
showTimeSelectOnly
|
||||
timeIntervals={15}
|
||||
@ -215,6 +201,8 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
||||
dateFormat="h:mm aa"
|
||||
customInput={<CustomTimeInput />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<DueDatePickerWrapper>
|
||||
<DatePicker
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import styled, {css} from 'styled-components/macro';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styled, { css } from 'styled-components/macro';
|
||||
|
||||
const InputWrapper = styled.div<{width: string}>`
|
||||
const InputWrapper = styled.div<{ width: string }>`
|
||||
position: relative;
|
||||
width: ${props => props.width};
|
||||
display: flex;
|
||||
@ -14,7 +14,7 @@ const InputWrapper = styled.div<{width: string}>`
|
||||
margin-top: 24px;
|
||||
`;
|
||||
|
||||
const InputLabel = styled.span<{width: string}>`
|
||||
const InputLabel = styled.span<{ width: string }>`
|
||||
width: ${props => props.width};
|
||||
padding: 0.7rem !important;
|
||||
color: #c2c6dc;
|
||||
@ -87,9 +87,8 @@ type InputProps = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const Input = React.forwardRef(
|
||||
@ -102,40 +101,34 @@ const Input = React.forwardRef(
|
||||
placeholder,
|
||||
icon,
|
||||
name,
|
||||
onChange,
|
||||
className,
|
||||
onClick,
|
||||
floatingLabel,
|
||||
value: initialValue,
|
||||
defaultValue,
|
||||
id,
|
||||
}: InputProps,
|
||||
$ref: any,
|
||||
) => {
|
||||
const [value, setValue] = useState(initialValue ?? '');
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
setValue(initialValue);
|
||||
}
|
||||
}, [initialValue]);
|
||||
const [hasValue, setHasValue] = useState(false);
|
||||
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
|
||||
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.currentTarget.value);
|
||||
if (onChange) {
|
||||
onChange(e);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<InputWrapper className={className} width={width}>
|
||||
<InputInput
|
||||
hasValue={floatingLabel || value !== ''}
|
||||
onChange={() => {
|
||||
console.log(`change ${$ref}!`);
|
||||
if ($ref && $ref.current) {
|
||||
console.log(`value : ${$ref.current.value}`);
|
||||
setHasValue(($ref.current.value !== '' || floatingLabel) ?? false);
|
||||
}
|
||||
}}
|
||||
hasValue={hasValue}
|
||||
ref={$ref}
|
||||
id={id}
|
||||
name={name}
|
||||
onClick={onClick}
|
||||
onChange={handleChange}
|
||||
autoComplete={autocomplete ? 'on' : 'off'}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
hasIcon={typeof icon !== 'undefined'}
|
||||
width={width}
|
||||
placeholder={placeholder}
|
||||
|
@ -93,6 +93,8 @@ export const Default = () => {
|
||||
onTaskDrop={onCardDrop}
|
||||
onTaskGroupDrop={onListDrop}
|
||||
onChangeTaskGroupName={action('change group name')}
|
||||
cardLabelVariant="large"
|
||||
onCardLabelClick={action('label click')}
|
||||
onCreateTaskGroup={action('create list')}
|
||||
onExtraMenuOpen={action('extra menu open')}
|
||||
onCardMemberClick={action('card member click')}
|
||||
|
@ -26,17 +26,21 @@ interface SimpleProps {
|
||||
onCreateTaskGroup: (listName: string) => void;
|
||||
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
onCardMemberClick: OnCardMemberClick;
|
||||
onCardLabelClick: () => void;
|
||||
cardLabelVariant: CardLabelVariant;
|
||||
}
|
||||
|
||||
const SimpleLists: React.FC<SimpleProps> = ({
|
||||
taskGroups,
|
||||
onTaskDrop,
|
||||
onChangeTaskGroupName,
|
||||
onCardLabelClick,
|
||||
onTaskGroupDrop,
|
||||
onTaskClick,
|
||||
onCreateTask,
|
||||
onQuickEditorOpen,
|
||||
onCreateTaskGroup,
|
||||
cardLabelVariant,
|
||||
onExtraMenuOpen,
|
||||
onCardMemberClick,
|
||||
}) => {
|
||||
@ -158,10 +162,12 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
||||
{taskProvided => {
|
||||
return (
|
||||
<Card
|
||||
labelVariant={cardLabelVariant}
|
||||
wrapperProps={{
|
||||
...taskProvided.draggableProps,
|
||||
...taskProvided.dragHandleProps,
|
||||
}}
|
||||
onCardLabelClick={onCardLabelClick}
|
||||
ref={taskProvided.innerRef}
|
||||
taskID={task.id}
|
||||
complete={task.complete ?? false}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
||||
import styled from 'styled-components';
|
||||
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
||||
|
||||
const WhiteCheckmark = styled(Checkmark)`
|
||||
fill: rgba(${props => props.theme.colors.text.secondary});
|
||||
@ -39,7 +39,9 @@ const LabelManager = ({ labelColors, label, onLabelEdit, onLabelDelete }: Props)
|
||||
/>
|
||||
<FieldLabel>Select a color</FieldLabel>
|
||||
<div>
|
||||
{labelColors.map((labelColor: LabelColor) => (
|
||||
{labelColors
|
||||
.filter(l => l.name !== 'no_color')
|
||||
.map((labelColor: LabelColor) => (
|
||||
<LabelBox
|
||||
key={labelColor.id}
|
||||
color={labelColor.colorHex}
|
||||
|
@ -40,7 +40,7 @@ export const ListSeparator = styled.hr`
|
||||
type Props = {
|
||||
onDeleteProject: () => void;
|
||||
};
|
||||
const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
|
||||
const ProjectSettings: React.FC<Props> = ({onDeleteProject}) => {
|
||||
return (
|
||||
<>
|
||||
<ListActionsWrapper>
|
||||
@ -55,7 +55,7 @@ const ProjectSettings: React.FC<Props> = ({ onDeleteProject }) => {
|
||||
type TeamSettingsProps = {
|
||||
onDeleteTeam: () => void;
|
||||
};
|
||||
export const TeamSettings: React.FC<TeamSettingsProps> = ({ onDeleteTeam }) => {
|
||||
export const TeamSettings: React.FC<TeamSettingsProps> = ({onDeleteTeam}) => {
|
||||
return (
|
||||
<>
|
||||
<ListActionsWrapper>
|
||||
@ -74,6 +74,7 @@ const ConfirmSubTitle = styled.h3`
|
||||
`;
|
||||
|
||||
const ConfirmDescription = styled.div`
|
||||
margin: 0 12px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
@ -83,7 +84,7 @@ const DeleteList = styled.ul`
|
||||
const DeleteListItem = styled.li`
|
||||
padding: 6px 0;
|
||||
list-style: disc;
|
||||
margin-left: 12px;
|
||||
margin-left: 16px;
|
||||
`;
|
||||
|
||||
const ConfirmDeleteButton = styled(Button)`
|
||||
@ -108,7 +109,7 @@ export const DELETE_INFO = {
|
||||
},
|
||||
};
|
||||
|
||||
const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems, onConfirmDelete }) => {
|
||||
const DeleteConfirm: React.FC<DeleteConfirmProps> = ({description, deletedItems, onConfirmDelete}) => {
|
||||
return (
|
||||
<ConfirmWrapper>
|
||||
<ConfirmDescription>
|
||||
@ -126,5 +127,5 @@ const DeleteConfirm: React.FC<DeleteConfirmProps> = ({ description, deletedItems
|
||||
);
|
||||
};
|
||||
|
||||
export { DeleteConfirm };
|
||||
export {DeleteConfirm};
|
||||
export default ProjectSettings;
|
||||
|
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}
|
||||
profile={profile.profileIcon}
|
||||
/>
|
||||
<Input value={profile.fullName} width="100%" label="Name" />
|
||||
<Input defaultValue={profile.fullName} width="100%" label="Name" />
|
||||
<Input
|
||||
value={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
||||
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
||||
width="100%"
|
||||
label="Initials "
|
||||
/>
|
||||
<Input value={profile.username ?? ''} width="100%" label="Username " />
|
||||
<Input defaultValue={profile.username ?? ''} width="100%" label="Username " />
|
||||
<Input width="100%" label="Email" />
|
||||
<Input width="100%" label="Bio" />
|
||||
<SettingActions>
|
||||
|
@ -267,6 +267,7 @@ export type Mutation = {
|
||||
updateTaskLocation: UpdateTaskLocationPayload;
|
||||
updateTaskName: Task;
|
||||
updateTeamMemberRole: UpdateTeamMemberRolePayload;
|
||||
updateUserPassword: UpdateUserPasswordPayload;
|
||||
updateUserRole: UpdateUserRolePayload;
|
||||
};
|
||||
|
||||
@ -506,6 +507,11 @@ export type MutationUpdateTeamMemberRoleArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateUserPasswordArgs = {
|
||||
input: UpdateUserPassword;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateUserRoleArgs = {
|
||||
input: UpdateUserRole;
|
||||
};
|
||||
@ -862,6 +868,17 @@ export type SetTeamOwnerPayload = {
|
||||
newOwner: Member;
|
||||
};
|
||||
|
||||
export type UpdateUserPassword = {
|
||||
userID: Scalars['UUID'];
|
||||
password: Scalars['String'];
|
||||
};
|
||||
|
||||
export type UpdateUserPasswordPayload = {
|
||||
__typename?: 'UpdateUserPasswordPayload';
|
||||
ok: Scalars['Boolean'];
|
||||
user: UserAccount;
|
||||
};
|
||||
|
||||
export type UpdateUserRole = {
|
||||
userID: Scalars['UUID'];
|
||||
roleCode: RoleCode;
|
||||
|
@ -13581,10 +13581,10 @@ react-helmet-async@^1.0.2:
|
||||
react-fast-compare "^2.0.4"
|
||||
shallowequal "^1.1.0"
|
||||
|
||||
react-hook-form@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.2.0.tgz#b5b654516ee03d55d78b7b9e194c7f4632885426"
|
||||
integrity sha512-EqGCSl3DxSUBtL/9lFvrFQLJ7ICdVKrfjcMHay2SvmU4trR8aqrd7YuiLSojBKmZBRdBnCcxG+LzLWF9z474NA==
|
||||
react-hook-form@^6.0.6:
|
||||
version "6.0.6"
|
||||
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.0.6.tgz#72ac1668aeaddfd642bcfb324cebe1ba237fb13e"
|
||||
integrity sha512-qxWhV++1V7SKKlr2hHFsessGwATCdexgVsByxOHltDyO9F0VWB1WN4ZvxnKuHTVGwjj6CLZo0mL+Hgy0QH1sAw==
|
||||
|
||||
react-hotkeys@2.0.0:
|
||||
version "2.0.0"
|
||||
|
@ -9,8 +9,16 @@ import (
|
||||
|
||||
var jwtKey = []byte("citadel_test_key")
|
||||
|
||||
type RestrictedMode string
|
||||
|
||||
const (
|
||||
Unrestricted RestrictedMode = "unrestricted"
|
||||
InstallOnly = "install_only"
|
||||
)
|
||||
|
||||
type AccessTokenClaims struct {
|
||||
UserID string `json:"userId"`
|
||||
Restricted RestrictedMode `json:"restricted"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
@ -31,10 +39,11 @@ func (r *ErrMalformedToken) Error() string {
|
||||
return "token is malformed"
|
||||
}
|
||||
|
||||
func NewAccessToken(userID string) (string, error) {
|
||||
func NewAccessToken(userID string, restrictedMode RestrictedMode) (string, error) {
|
||||
accessExpirationTime := time.Now().Add(5 * time.Second)
|
||||
accessClaims := &AccessTokenClaims{
|
||||
UserID: userID,
|
||||
Restricted: restrictedMode,
|
||||
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
||||
}
|
||||
|
||||
@ -50,6 +59,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
|
||||
accessExpirationTime := time.Now().Add(dur)
|
||||
accessClaims := &AccessTokenClaims{
|
||||
UserID: userID,
|
||||
Restricted: Unrestricted,
|
||||
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
||||
}
|
||||
|
||||
|
@ -58,6 +58,12 @@ type Role struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SystemOption struct {
|
||||
OptionID uuid.UUID `json:"option_id"`
|
||||
Key string `json:"key"`
|
||||
Value sql.NullString `json:"value"`
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
TaskID uuid.UUID `json:"task_id"`
|
||||
TaskGroupID uuid.UUID `json:"task_group_id"`
|
||||
|
@ -15,6 +15,7 @@ type Querier interface {
|
||||
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
|
||||
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
|
||||
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
|
||||
CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error)
|
||||
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
|
||||
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
|
||||
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
|
||||
@ -61,6 +62,7 @@ type Querier interface {
|
||||
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
|
||||
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
|
||||
GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error)
|
||||
GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error)
|
||||
GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error)
|
||||
GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error)
|
||||
GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error)
|
||||
|
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/playground"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/project-citadel/api/internal/auth"
|
||||
"github.com/jordanknott/project-citadel/api/internal/config"
|
||||
"github.com/jordanknott/project-citadel/api/internal/db"
|
||||
)
|
||||
@ -49,7 +50,13 @@ func NewHandler(config config.AppConfig, repo db.Repository) http.Handler {
|
||||
func NewPlaygroundHandler(endpoint string) http.Handler {
|
||||
return playground.Handler("GraphQL Playground", endpoint)
|
||||
}
|
||||
|
||||
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
|
||||
userID, ok := ctx.Value("userID").(uuid.UUID)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) {
|
||||
restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode)
|
||||
return restricted, ok
|
||||
}
|
||||
|
@ -706,6 +706,7 @@ func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTea
|
||||
if err != nil {
|
||||
return &DeleteTeamMemberPayload{}, err
|
||||
}
|
||||
|
||||
_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID})
|
||||
if err != nil {
|
||||
return &DeleteTeamMemberPayload{}, err
|
||||
@ -992,7 +993,10 @@ func (r *queryResolver) Me(ctx context.Context) (*db.UserAccount, error) {
|
||||
return &db.UserAccount{}, fmt.Errorf("internal server error")
|
||||
}
|
||||
user, err := r.Repository.GetUserAccountByID(ctx, userID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
log.WithFields(log.Fields{"userID": userID}).Warning("can not find user for me query")
|
||||
return &db.UserAccount{}, nil
|
||||
} else if err != nil {
|
||||
return &db.UserAccount{}, err
|
||||
}
|
||||
return &user, err
|
||||
|
@ -1,11 +1,11 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jordanknott/project-citadel/api/internal/auth"
|
||||
@ -18,23 +18,26 @@ var jwtKey = []byte("citadel_test_key")
|
||||
|
||||
type authResource struct{}
|
||||
|
||||
type AccessTokenClaims struct {
|
||||
UserID string `json:"userId"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
type RefreshTokenClaims struct {
|
||||
UserID string `json:"userId"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
type LoginRequestData struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type NewUserAccount struct {
|
||||
FullName string `json:"fullname"`
|
||||
Username string
|
||||
Password string
|
||||
Initials string
|
||||
Email string
|
||||
}
|
||||
|
||||
type InstallRequestData struct {
|
||||
User NewUserAccount
|
||||
}
|
||||
|
||||
type LoginResponseData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
IsInstalled bool `json:"isInstalled"`
|
||||
}
|
||||
|
||||
type LogoutResponseData struct {
|
||||
@ -51,18 +54,48 @@ type AvatarUploadResponseData struct {
|
||||
}
|
||||
|
||||
func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
|
||||
if err == sql.ErrNoRows {
|
||||
user, err := h.repo.GetUserAccountByUsername(r.Context(), "system")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: false})
|
||||
|
||||
return
|
||||
} else if err != nil {
|
||||
log.WithError(err).Error("get system option")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c, err := r.Cookie("refreshToken")
|
||||
if err != nil {
|
||||
if err == http.ErrNoCookie {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.WithError(err).Error("unknown error")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
refreshTokenID := uuid.MustParse(c.Value)
|
||||
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
||||
log.WithError(err).WithFields(log.Fields{"refreshTokenID": refreshTokenID.String()}).Error("no tokens found")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.WithError(err).Error("token retrieve failure")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@ -76,7 +109,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
accessTokenString, err := auth.NewAccessToken(token.UserID.String())
|
||||
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
@ -88,7 +121,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
|
||||
Expires: refreshExpiresAt,
|
||||
HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: true})
|
||||
}
|
||||
|
||||
func (h *CitadelHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@ -142,7 +175,7 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||
|
||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String())
|
||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
@ -154,7 +187,68 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
Expires: refreshExpiresAt,
|
||||
HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
||||
}
|
||||
|
||||
func (h *CitadelHandler) InstallHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok {
|
||||
if restricted != auth.InstallOnly {
|
||||
log.Warning("attempted to install without install only restriction")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
|
||||
if err != sql.ErrNoRows {
|
||||
log.WithError(err).Error("install handler called even though system is installed")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var requestData InstallRequestData
|
||||
err = json.NewDecoder(r.Body).Decode(&requestData)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{"r": requestData}).Info("install")
|
||||
|
||||
createdAt := time.Now().UTC()
|
||||
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
|
||||
user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{
|
||||
Username: requestData.User.Username,
|
||||
Initials: requestData.User.Initials,
|
||||
Email: requestData.User.Email,
|
||||
PasswordHash: string(hashedPwd),
|
||||
CreatedAt: createdAt,
|
||||
RoleCode: "admin",
|
||||
})
|
||||
|
||||
_, err = h.repo.CreateSystemOption(r.Context(), db.CreateSystemOptionParams{Key: "is_installed", Value: sql.NullString{Valid: true, String: "true"}})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
refreshCreatedAt := time.Now().UTC()
|
||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||
|
||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-type", "application/json")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "refreshToken",
|
||||
Value: refreshTokenString.TokenID.String(),
|
||||
Expires: refreshExpiresAt,
|
||||
HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
||||
}
|
||||
|
||||
func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router {
|
||||
|
@ -40,13 +40,19 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
|
||||
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 {
|
||||
log.Error(err)
|
||||
log.WithError(err).Error("middleware access token userID parse")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "userID", userID)
|
||||
ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
@ -104,6 +104,7 @@ func NewRouter(config config.AppConfig, dbConnection *sqlx.DB) (chi.Router, erro
|
||||
r.Group(func(mux chi.Router) {
|
||||
mux.Use(AuthenticationMiddleware)
|
||||
mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload)
|
||||
mux.Post("/auth/install", citadelHandler.InstallHandler)
|
||||
mux.Handle("/graphql", graph.NewHandler(config, *repository))
|
||||
})
|
||||
|
||||
|
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