feature: add first time install process
This commit is contained in:
parent
90515f6aa4
commit
2cf6be082c
@ -24,7 +24,7 @@
|
|||||||
"plugin:@typescript-eslint/recommended"
|
"plugin:@typescript-eslint/recommended"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"prettier/prettier": "warning",
|
"prettier/prettier": "error",
|
||||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||||
"@typescript-eslint/explicit-function-return-type": "off",
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
@ -61,7 +61,7 @@
|
|||||||
"react-beautiful-dnd": "^13.0.0",
|
"react-beautiful-dnd": "^13.0.0",
|
||||||
"react-datepicker": "^2.14.1",
|
"react-datepicker": "^2.14.1",
|
||||||
"react-dom": "^16.12.0",
|
"react-dom": "^16.12.0",
|
||||||
"react-hook-form": "^5.2.0",
|
"react-hook-form": "^6.0.6",
|
||||||
"react-markdown": "^4.3.1",
|
"react-markdown": "^4.3.1",
|
||||||
"react-router": "^5.1.2",
|
"react-router": "^5.1.2",
|
||||||
"react-router-dom": "^5.1.2",
|
"react-router-dom": "^5.1.2",
|
||||||
|
@ -8,6 +8,7 @@ import Projects from 'Projects';
|
|||||||
import Project from 'Projects/Project';
|
import Project from 'Projects/Project';
|
||||||
import Teams from 'Teams';
|
import Teams from 'Teams';
|
||||||
import Login from 'Auth';
|
import Login from 'Auth';
|
||||||
|
import Install from 'Install';
|
||||||
import Profile from 'Profile';
|
import Profile from 'Profile';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ type RoutesProps = {
|
|||||||
const Routes = ({history}: RoutesProps) => (
|
const Routes = ({history}: RoutesProps) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/login" component={Login} />
|
<Route exact path="/login" component={Login} />
|
||||||
|
<Route exact path="/install" component={Install} />
|
||||||
<MainContent>
|
<MainContent>
|
||||||
<Route exact path="/" component={Dashboard} />
|
<Route exact path="/" component={Dashboard} />
|
||||||
<Route exact path="/projects" component={Projects} />
|
<Route exact path="/projects" component={Projects} />
|
||||||
|
@ -196,7 +196,7 @@ export const ProjectPopup: React.FC<ProjectPopupProps> = ({history, name, projec
|
|||||||
<Popup title={null} tab={0}>
|
<Popup title={null} tab={0}>
|
||||||
<ProjectSettings
|
<ProjectSettings
|
||||||
onDeleteProject={() => {
|
onDeleteProject={() => {
|
||||||
setTab(1);
|
setTab(1, 300);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Popup>
|
</Popup>
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import jwtDecode from 'jwt-decode';
|
import jwtDecode from 'jwt-decode';
|
||||||
import { createBrowserHistory } from 'history';
|
import { createBrowserHistory } from 'history';
|
||||||
|
import { Router } from 'react-router';
|
||||||
|
import { PopupProvider } from 'shared/components/PopupMenu';
|
||||||
import { setAccessToken } from 'shared/utils/accessToken';
|
import { setAccessToken } from 'shared/utils/accessToken';
|
||||||
import styled, { ThemeProvider } from 'styled-components';
|
import styled, { ThemeProvider } from 'styled-components';
|
||||||
import NormalizeStyles from './NormalizeStyles';
|
import NormalizeStyles from './NormalizeStyles';
|
||||||
@ -9,10 +11,12 @@ import {theme} from './ThemeStyles';
|
|||||||
import Routes from './Routes';
|
import Routes from './Routes';
|
||||||
import { UserIDContext } from './context';
|
import { UserIDContext } from './context';
|
||||||
import Navbar from './Navbar';
|
import Navbar from './Navbar';
|
||||||
import {Router} from 'react-router';
|
|
||||||
import {PopupProvider} from 'shared/components/PopupMenu';
|
|
||||||
|
|
||||||
const history = createBrowserHistory();
|
const history = createBrowserHistory();
|
||||||
|
type RefreshTokenResponse = {
|
||||||
|
accessToken: string;
|
||||||
|
isInstalled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -28,10 +32,13 @@ const App = () => {
|
|||||||
history.replace('/login');
|
history.replace('/login');
|
||||||
} else {
|
} else {
|
||||||
const response: RefreshTokenResponse = await x.json();
|
const response: RefreshTokenResponse = await x.json();
|
||||||
const {accessToken} = response;
|
const { accessToken, isInstalled } = response;
|
||||||
const claims: JWTToken = jwtDecode(accessToken);
|
const claims: JWTToken = jwtDecode(accessToken);
|
||||||
setUserID(claims.userId);
|
setUserID(claims.userId);
|
||||||
setAccessToken(accessToken);
|
setAccessToken(accessToken);
|
||||||
|
if (!isInstalled) {
|
||||||
|
history.replace('/install');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
@ -16,7 +16,7 @@ const Auth = () => {
|
|||||||
const login = (
|
const login = (
|
||||||
data: LoginFormData,
|
data: LoginFormData,
|
||||||
setComplete: (val: boolean) => void,
|
setComplete: (val: boolean) => void,
|
||||||
setError: (field: string, eType: string, message: string) => void,
|
setError: (name: 'username' | 'password', error: ErrorOption) => void,
|
||||||
) => {
|
) => {
|
||||||
fetch('/auth/login', {
|
fetch('/auth/login', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@ -28,8 +28,8 @@ const Auth = () => {
|
|||||||
}).then(async x => {
|
}).then(async x => {
|
||||||
if (x.status === 401) {
|
if (x.status === 401) {
|
||||||
setInvalidLoginAttempt(invalidLoginAttempt + 1);
|
setInvalidLoginAttempt(invalidLoginAttempt + 1);
|
||||||
setError('username', 'invalid', 'Invalid username');
|
setError('username', { type: 'error', message: 'Invalid username' });
|
||||||
setError('password', 'invalid', 'Invalid password');
|
setError('password', { type: 'error', message: 'Invalid password' });
|
||||||
setComplete(true);
|
setComplete(true);
|
||||||
} else {
|
} else {
|
||||||
const response = await x.json();
|
const response = await x.json();
|
||||||
|
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;
|
@ -4,7 +4,7 @@ import updateApolloCache from 'shared/utils/cache';
|
|||||||
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
||||||
import styled, { css } from 'styled-components/macro';
|
import styled, { css } from 'styled-components/macro';
|
||||||
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
|
import { Bolt, ToggleOn, Tags, CheckCircle, Sort, Filter } from 'shared/icons';
|
||||||
import LabelManagerEditor from '../LabelManagerEditor'
|
import LabelManagerEditor from '../LabelManagerEditor';
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||||
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
|
import { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@ -103,9 +103,12 @@ const initialQuickCardEditorState: QuickCardEditorState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ProjectBoardProps = {
|
type ProjectBoardProps = {
|
||||||
|
onCardLabelClick: () => void;
|
||||||
|
cardLabelVariant: CardLabelVariant;
|
||||||
projectID: string;
|
projectID: string;
|
||||||
};
|
};
|
||||||
const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|
||||||
|
const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick, cardLabelVariant }) => {
|
||||||
const [assignTask] = useAssignTaskMutation();
|
const [assignTask] = useAssignTaskMutation();
|
||||||
const [unassignTask] = useUnassignTaskMutation();
|
const [unassignTask] = useUnassignTaskMutation();
|
||||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
const $labelsRef = useRef<HTMLDivElement>(null);
|
||||||
@ -156,7 +159,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
updateApolloCache<FindProjectQuery>(
|
updateApolloCache<FindProjectQuery>(
|
||||||
client,
|
client,
|
||||||
FindProjectDocument,
|
FindProjectDocument,
|
||||||
cache => produce(cache, draftCache => {
|
cache =>
|
||||||
|
produce(cache, draftCache => {
|
||||||
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
|
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
|
||||||
}),
|
}),
|
||||||
{ projectId: projectID },
|
{ projectId: projectID },
|
||||||
@ -341,6 +345,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
|||||||
onTaskClick={task => {
|
onTaskClick={task => {
|
||||||
history.push(`${match.url}/c/${task.id}`);
|
history.push(`${match.url}/c/${task.id}`);
|
||||||
}}
|
}}
|
||||||
|
onCardLabelClick={onCardLabelClick}
|
||||||
|
cardLabelVariant={cardLabelVariant}
|
||||||
onTaskDrop={(droppedTask, previousTaskGroupID) => {
|
onTaskDrop={(droppedTask, previousTaskGroupID) => {
|
||||||
updateTaskLocation({
|
updateTaskLocation({
|
||||||
variables: {
|
variables: {
|
||||||
|
@ -92,7 +92,7 @@ const CreateChecklistPopup: React.FC<CreateChecklistPopupProps> = ({ onCreateChe
|
|||||||
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
|
<CreateChecklistForm onSubmit={handleSubmit(createUser)}>
|
||||||
<CreateChecklistInput
|
<CreateChecklistInput
|
||||||
floatingLabel
|
floatingLabel
|
||||||
value="Checklist"
|
defaultValue="Checklist"
|
||||||
width="100%"
|
width="100%"
|
||||||
label="Name"
|
label="Name"
|
||||||
id="name"
|
id="name"
|
||||||
@ -194,8 +194,10 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
cache =>
|
cache =>
|
||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
const { checklists } = cache.findTask;
|
const { checklists } = cache.findTask;
|
||||||
console.log(deleteData)
|
console.log(deleteData);
|
||||||
draftCache.findTask.checklists = checklists.filter(c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id);
|
draftCache.findTask.checklists = checklists.filter(
|
||||||
|
c => c.id !== deleteData.data.deleteTaskChecklist.taskChecklist.id,
|
||||||
|
);
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||||
draftCache.findTask.badges.checklist = {
|
draftCache.findTask.badges.checklist = {
|
||||||
__typename: 'ChecklistBadge',
|
__typename: 'ChecklistBadge',
|
||||||
@ -234,9 +236,11 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
cache =>
|
cache =>
|
||||||
produce(cache, draftCache => {
|
produce(cache, draftCache => {
|
||||||
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
const item = deleteData.data.deleteTaskChecklistItem.taskChecklistItem;
|
||||||
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID)
|
const targetIdx = cache.findTask.checklists.findIndex(c => c.id === item.taskChecklistID);
|
||||||
if (targetIdx > -1) {
|
if (targetIdx > -1) {
|
||||||
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(c => item.id !== c.id);
|
draftCache.findTask.checklists[targetIdx].items = cache.findTask.checklists[targetIdx].items.filter(
|
||||||
|
c => item.id !== c.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
const { complete, total } = calculateChecklistBadge(draftCache.findTask.checklists);
|
||||||
draftCache.findTask.badges.checklist = {
|
draftCache.findTask.badges.checklist = {
|
||||||
@ -372,9 +376,9 @@ const Details: React.FC<DetailsProps> = ({
|
|||||||
__typename: 'TaskChecklistItem',
|
__typename: 'TaskChecklistItem',
|
||||||
id: itemID,
|
id: itemID,
|
||||||
taskChecklistID: checklistID,
|
taskChecklistID: checklistID,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onToggleChecklistItem={(itemID, complete) => {
|
onToggleChecklistItem={(itemID, complete) => {
|
||||||
|
@ -4,8 +4,16 @@ import updateApolloCache from 'shared/utils/cache';
|
|||||||
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components/macro';
|
||||||
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
import { usePopup, Popup } from 'shared/components/PopupMenu';
|
||||||
import LabelManagerEditor from './LabelManagerEditor'
|
import LabelManagerEditor from './LabelManagerEditor';
|
||||||
import {useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation, Redirect} from 'react-router-dom';
|
import {
|
||||||
|
useParams,
|
||||||
|
Route,
|
||||||
|
useRouteMatch,
|
||||||
|
useHistory,
|
||||||
|
RouteComponentProps,
|
||||||
|
useLocation,
|
||||||
|
Redirect,
|
||||||
|
} from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useSetProjectOwnerMutation,
|
useSetProjectOwnerMutation,
|
||||||
useUpdateProjectMemberRoleMutation,
|
useUpdateProjectMemberRoleMutation,
|
||||||
@ -29,8 +37,20 @@ import produce from 'immer';
|
|||||||
import UserIDContext from 'App/context';
|
import UserIDContext from 'App/context';
|
||||||
import Input from 'shared/components/Input';
|
import Input from 'shared/components/Input';
|
||||||
import Member from 'shared/components/Member';
|
import Member from 'shared/components/Member';
|
||||||
import Board from './Board'
|
import Board from './Board';
|
||||||
import Details from './Details'
|
import Details from './Details';
|
||||||
|
|
||||||
|
const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant';
|
||||||
|
|
||||||
|
const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch<React.SetStateAction<string>>] => {
|
||||||
|
const [value, setValue] = React.useState<string>(localStorage.getItem(localStorageKey) || '');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.setItem(localStorageKey, value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
};
|
||||||
|
|
||||||
const SearchInput = styled(Input)`
|
const SearchInput = styled(Input)`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -110,6 +130,8 @@ const Project = () => {
|
|||||||
console.log(taskLabelsRef.current);
|
console.log(taskLabelsRef.current);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
|
||||||
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
||||||
|
|
||||||
const [deleteTask] = useDeleteTaskMutation();
|
const [deleteTask] = useDeleteTaskMutation();
|
||||||
@ -224,17 +246,18 @@ const Project = () => {
|
|||||||
projectID={projectID}
|
projectID={projectID}
|
||||||
name={data.findProject.name}
|
name={data.findProject.name}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path={`${match.path}`} exact render={() => <Redirect to={`${match.url}/board`} />} />
|
||||||
path={`${match.path}`}
|
|
||||||
exact
|
|
||||||
render={() => (
|
|
||||||
<Redirect to={`${match.url}/board`} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path={`${match.path}/board`}
|
path={`${match.path}/board`}
|
||||||
render={() => (
|
render={() => (
|
||||||
<Board projectID={projectID} />
|
<Board
|
||||||
|
cardLabelVariant={value === 'small' ? 'large' : 'small'}
|
||||||
|
onCardLabelClick={() => {
|
||||||
|
const variant = value === 'small' ? 'large' : 'small';
|
||||||
|
setValue(() => variant);
|
||||||
|
}}
|
||||||
|
projectID={projectID}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
|
30
frontend/src/citadel.d.ts
vendored
30
frontend/src/citadel.d.ts
vendored
@ -47,6 +47,7 @@ type TaskUser = {
|
|||||||
|
|
||||||
type RefreshTokenResponse = {
|
type RefreshTokenResponse = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
isInstalled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoginFormData = {
|
type LoginFormData = {
|
||||||
@ -54,16 +55,41 @@ type LoginFormData = {
|
|||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RegisterFormData = {
|
||||||
|
username: string;
|
||||||
|
fullname: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
password_confirm: string;
|
||||||
|
initials: string;
|
||||||
|
};
|
||||||
|
|
||||||
type DueDateFormData = {
|
type DueDateFormData = {
|
||||||
endDate: string;
|
endDate: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
};
|
};
|
||||||
|
type ErrorOption =
|
||||||
|
| {
|
||||||
|
types: MultipleFieldErrors;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
message?: Message;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterProps = {
|
||||||
|
onSubmit: (
|
||||||
|
data: RegisterFormData,
|
||||||
|
setComplete: (val: boolean) => void,
|
||||||
|
setError: (name: 'username' | 'email' | 'password' | 'password_confirm' | 'initials', error: ErrorOption) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type LoginProps = {
|
type LoginProps = {
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
data: LoginFormData,
|
data: LoginFormData,
|
||||||
setComplete: (val: boolean) => void,
|
setComplete: (val: boolean) => void,
|
||||||
setError: (field: string, eType: string, message: string) => void,
|
setError: (name: 'username' | 'password', error: ErrorOption) => void,
|
||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -85,3 +111,5 @@ type ElementBounds = {
|
|||||||
size: ElementSize;
|
size: ElementSize;
|
||||||
position: ElementPosition;
|
position: ElementPosition;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CardLabelVariant = 'large' | 'small';
|
||||||
|
@ -121,7 +121,7 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
onChangeRole,
|
onChangeRole,
|
||||||
}) => {
|
}) => {
|
||||||
const { hidePopup, setTab } = usePopup();
|
const { hidePopup, setTab } = usePopup();
|
||||||
const [userPass, setUserPass] = useState({ pass: "", passConfirm: "" });
|
const [userPass, setUserPass] = useState({ pass: '', passConfirm: '' });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Popup title={null} tab={0}>
|
<Popup title={null} tab={0}>
|
||||||
@ -137,9 +137,13 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
<CurrentPermission>{`(${user.role.name})`}</CurrentPermission>
|
||||||
</MiniProfileActionItem>
|
</MiniProfileActionItem>
|
||||||
)}
|
)}
|
||||||
<MiniProfileActionItem onClick={() => {
|
<MiniProfileActionItem
|
||||||
setTab(3)
|
onClick={() => {
|
||||||
}}>Reset password...</MiniProfileActionItem>
|
setTab(3);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset password...
|
||||||
|
</MiniProfileActionItem>
|
||||||
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
|
<MiniProfileActionItem onClick={() => setTab(5)}>Remove from organzation...</MiniProfileActionItem>
|
||||||
</MiniProfileActionWrapper>
|
</MiniProfileActionWrapper>
|
||||||
</MiniProfileActions>
|
</MiniProfileActions>
|
||||||
@ -214,23 +218,39 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
|
<Popup title="Reset password?" onClose={() => hidePopup()} tab={3}>
|
||||||
<Content>
|
<Content>
|
||||||
<DeleteDescription>
|
<DeleteDescription>
|
||||||
You can either set the user's new password directly or send the user an email allowing them to reset their own password.
|
You can either set the user's new password directly or send the user an email allowing them to reset their
|
||||||
|
own password.
|
||||||
</DeleteDescription>
|
</DeleteDescription>
|
||||||
<UserPassBar>
|
<UserPassBar>
|
||||||
<UserPassButton onClick={() => setTab(4)} color="warning">Set password...</UserPassButton>
|
<UserPassButton onClick={() => setTab(4)} color="warning">
|
||||||
<UserPassButton color="warning" variant="outline">Send reset link</UserPassButton>
|
Set password...
|
||||||
|
</UserPassButton>
|
||||||
|
<UserPassButton color="warning" variant="outline">
|
||||||
|
Send reset link
|
||||||
|
</UserPassButton>
|
||||||
</UserPassBar>
|
</UserPassBar>
|
||||||
</Content>
|
</Content>
|
||||||
</Popup>
|
</Popup>
|
||||||
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
<Popup title="Reset password" onClose={() => hidePopup()} tab={4}>
|
||||||
<Content>
|
<Content>
|
||||||
<NewUserPassInput onChange={e => setUserPass({ pass: e.currentTarget.value, passConfirm: userPass.passConfirm })} value={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
<NewUserPassInput defaultValue={userPass.pass} width="100%" variant="alternate" placeholder="New password" />
|
||||||
<NewUserPassInput onChange={e => setUserPass({ passConfirm: e.currentTarget.value, pass: userPass.pass })} value={userPass.passConfirm} width="100%" variant="alternate" placeholder="New password (confirm)" />
|
<NewUserPassInput
|
||||||
<UserPassConfirmButton disabled={userPass.pass === "" || userPass.passConfirm === ""} onClick={() => {
|
defaultValue={userPass.passConfirm}
|
||||||
|
width="100%"
|
||||||
|
variant="alternate"
|
||||||
|
placeholder="New password (confirm)"
|
||||||
|
/>
|
||||||
|
<UserPassConfirmButton
|
||||||
|
disabled={userPass.pass === '' || userPass.passConfirm === ''}
|
||||||
|
onClick={() => {
|
||||||
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
|
if (userPass.pass === userPass.passConfirm && updateUserPassword) {
|
||||||
updateUserPassword(user, userPass.pass)
|
updateUserPassword(user, userPass.pass);
|
||||||
}
|
}
|
||||||
}} color="danger">Set password</UserPassConfirmButton>
|
}}
|
||||||
|
color="danger"
|
||||||
|
>
|
||||||
|
Set password
|
||||||
|
</UserPassConfirmButton>
|
||||||
</Content>
|
</Content>
|
||||||
</Popup>
|
</Popup>
|
||||||
<Popup title="Remove user" onClose={() => hidePopup()} tab={5}>
|
<Popup title="Remove user" onClose={() => hidePopup()} tab={5}>
|
||||||
@ -238,11 +258,11 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
<DeleteDescription>
|
<DeleteDescription>
|
||||||
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
Removing this user from the organzation will remove them from assigned tasks, projects, and teams.
|
||||||
</DeleteDescription>
|
</DeleteDescription>
|
||||||
<DeleteDescription>
|
<DeleteDescription>The user is the owner of 3 projects & 2 teams.</DeleteDescription>
|
||||||
The user is the owner of 3 projects & 2 teams.
|
<UserSelect onChange={() => {}} value={null} options={[{ label: 'Jordan Knott', value: 'jordanknott' }]} />
|
||||||
</DeleteDescription>
|
<UserPassConfirmButton onClick={() => {}} color="danger">
|
||||||
<UserSelect onChange={() => { }} value={null} options={[{ label: 'Jordan Knott', value: "jordanknott" }]} />
|
Set password
|
||||||
<UserPassConfirmButton onClick={() => { }} color="danger">Set password</UserPassConfirmButton>
|
</UserPassConfirmButton>
|
||||||
</Content>
|
</Content>
|
||||||
</Popup>
|
</Popup>
|
||||||
</>
|
</>
|
||||||
@ -251,11 +271,11 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
|||||||
const UserSelect = styled(Select)`
|
const UserSelect = styled(Select)`
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
`
|
`;
|
||||||
|
|
||||||
const NewUserPassInput = styled(Input)`
|
const NewUserPassInput = styled(Input)`
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
`
|
`;
|
||||||
const InviteMemberButton = styled(Button)`
|
const InviteMemberButton = styled(Button)`
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
`;
|
`;
|
||||||
@ -263,11 +283,11 @@ const InviteMemberButton = styled(Button)`
|
|||||||
const UserPassBar = styled.div`
|
const UserPassBar = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
`
|
`;
|
||||||
const UserPassConfirmButton = styled(Button)`
|
const UserPassConfirmButton = styled(Button)`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
`
|
`;
|
||||||
|
|
||||||
const UserPassButton = styled(Button)`
|
const UserPassButton = styled(Button)`
|
||||||
width: 50%;
|
width: 50%;
|
||||||
@ -275,7 +295,7 @@ const UserPassButton = styled(Button)`
|
|||||||
& ~ & {
|
& ~ & {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
const MemberItemOptions = styled.div``;
|
const MemberItemOptions = styled.div``;
|
||||||
|
|
||||||
@ -639,7 +659,14 @@ type AdminProps = {
|
|||||||
onUpdateUserPassword: (user: TaskUser, password: string) => void;
|
onUpdateUserPassword: (user: TaskUser, password: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPassword, onDeleteUser, onInviteUser, users }) => {
|
const Admin: React.FC<AdminProps> = ({
|
||||||
|
initialTab,
|
||||||
|
onAddUser,
|
||||||
|
onUpdateUserPassword,
|
||||||
|
onDeleteUser,
|
||||||
|
onInviteUser,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
const warning =
|
const warning =
|
||||||
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
'You can’t leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
|
||||||
const [currentTop, setTop] = useState(initialTab * 48);
|
const [currentTop, setTop] = useState(initialTab * 48);
|
||||||
@ -647,7 +674,7 @@ const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPasswo
|
|||||||
const { showPopup, hidePopup } = usePopup();
|
const { showPopup, hidePopup } = usePopup();
|
||||||
const $tabNav = useRef<HTMLDivElement>(null);
|
const $tabNav = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [updateUserRole] = useUpdateUserRoleMutation()
|
const [updateUserRole] = useUpdateUserRoleMutation();
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<TabNav ref={$tabNav}>
|
<TabNav ref={$tabNav}>
|
||||||
@ -708,11 +735,11 @@ const Admin: React.FC<AdminProps> = ({ initialTab, onAddUser, onUpdateUserPasswo
|
|||||||
user={member}
|
user={member}
|
||||||
warning={member.role && member.role.code === 'owner' ? warning : null}
|
warning={member.role && member.role.code === 'owner' ? warning : null}
|
||||||
updateUserPassword={(user, password) => {
|
updateUserPassword={(user, password) => {
|
||||||
onUpdateUserPassword(user, password)
|
onUpdateUserPassword(user, password);
|
||||||
}}
|
}}
|
||||||
canChangeRole={member.role && member.role.code !== 'owner'}
|
canChangeRole={member.role && member.role.code !== 'owner'}
|
||||||
onChangeRole={roleCode => {
|
onChangeRole={roleCode => {
|
||||||
updateUserRole({ variables: { userID: member.id, roleCode } })
|
updateUserRole({ variables: { userID: member.id, roleCode } });
|
||||||
}}
|
}}
|
||||||
onRemoveFromTeam={
|
onRemoveFromTeam={
|
||||||
member.role && member.role.code === 'owner'
|
member.role && member.role.code === 'owner'
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import styled, {css} from 'styled-components';
|
import styled, { css, keyframes } from 'styled-components';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { mixin } from 'shared/utils/styles';
|
import { mixin } from 'shared/utils/styles';
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
import TextareaAutosize from 'react-autosize-textarea';
|
||||||
@ -102,28 +102,93 @@ export const ListCardDetails = styled.div<{complete: boolean}>`
|
|||||||
${props => props.complete && 'opacity: 0.6;'}
|
${props => props.complete && 'opacity: 0.6;'}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ListCardLabels = styled.div`
|
const labelVariantExpandAnimation = keyframes`
|
||||||
overflow: auto;
|
0% {min-width: 40px; height: 8px;}
|
||||||
position: relative;
|
50% {min-width: 56px; height: 8px;}
|
||||||
|
100% {min-width: 56px; height: 16px;}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ListCardLabel = styled.span`
|
const labelTextVariantExpandAnimation = keyframes`
|
||||||
height: 16px;
|
0% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||||
|
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||||
|
100% {transform: scale(1); visibility: visible; pointer-events: all;}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const labelVariantShrinkAnimation = keyframes`
|
||||||
|
0% {min-width: 56px; height: 16px;}
|
||||||
|
50% {min-width: 56px; height: 8px;}
|
||||||
|
100% {min-width: 40px; height: 8px;}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const labelTextVariantShrinkAnimation = keyframes`
|
||||||
|
0% {transform: scale(1); visibility: visible; pointer-events: all;}
|
||||||
|
75% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||||
|
100% {transform: scale(0); visibility: hidden; pointer-events: none;}
|
||||||
|
`;
|
||||||
|
export const ListCardLabelText = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ListCardLabel = styled.span<{ variant: 'small' | 'large' }>`
|
||||||
|
${props =>
|
||||||
|
props.variant === 'small'
|
||||||
|
? css`
|
||||||
|
height: 8px;
|
||||||
|
min-width: 40px;
|
||||||
|
& ${ListCardLabelText} {
|
||||||
|
transform: scale(0);
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: css`
|
||||||
|
height: 16px;
|
||||||
|
min-width: 56px;
|
||||||
|
`}
|
||||||
|
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
max-width: 198px;
|
max-width: 198px;
|
||||||
float: left;
|
float: left;
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 4px 4px 0;
|
margin: 0 4px 4px 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: block;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: ${props => props.color};
|
background-color: ${props => props.color};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const ListCardLabels = styled.div<{ toggleLabels: boolean; toggleDirection: 'expand' | 'shrink' }>`
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
${props =>
|
||||||
|
props.toggleLabels &&
|
||||||
|
props.toggleDirection === 'expand' &&
|
||||||
|
css`
|
||||||
|
& ${ListCardLabel} {
|
||||||
|
animation: ${labelVariantExpandAnimation} 0.45s ease-out;
|
||||||
|
}
|
||||||
|
& ${ListCardLabelText} {
|
||||||
|
animation: ${labelTextVariantExpandAnimation} 0.45s ease-out;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
${props =>
|
||||||
|
props.toggleLabels &&
|
||||||
|
props.toggleDirection === 'shrink' &&
|
||||||
|
css`
|
||||||
|
& ${ListCardLabel} {
|
||||||
|
animation: ${labelVariantShrinkAnimation} 0.45s ease-out;
|
||||||
|
}
|
||||||
|
& ${ListCardLabelText} {
|
||||||
|
animation: ${labelTextVariantShrinkAnimation} 0.45s ease-out;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
export const ListCardOperation = styled.span`
|
export const ListCardOperation = styled.span`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
@ -136,7 +201,7 @@ export const ListCardOperation = styled.span`
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${props => mixin.darken('#262c49', .25)};
|
background-color: ${props => mixin.darken('#262c49', 0.25)};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ import {
|
|||||||
ClockIcon,
|
ClockIcon,
|
||||||
ListCardLabels,
|
ListCardLabels,
|
||||||
ListCardLabel,
|
ListCardLabel,
|
||||||
|
ListCardLabelText,
|
||||||
ListCardOperation,
|
ListCardOperation,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardMembers,
|
CardMembers,
|
||||||
@ -47,10 +48,12 @@ type Props = {
|
|||||||
watched?: boolean;
|
watched?: boolean;
|
||||||
wrapperProps?: any;
|
wrapperProps?: any;
|
||||||
members?: Array<TaskUser> | null;
|
members?: Array<TaskUser> | null;
|
||||||
|
onCardLabelClick?: () => void;
|
||||||
onCardMemberClick?: OnCardMemberClick;
|
onCardMemberClick?: OnCardMemberClick;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
|
onEditCard?: (taskGroupID: string, taskID: string, cardName: string) => void;
|
||||||
onCardTitleChange?: (name: string) => void;
|
onCardTitleChange?: (name: string) => void;
|
||||||
|
labelVariant?: CardLabelVariant;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Card = React.forwardRef(
|
const Card = React.forwardRef(
|
||||||
@ -69,14 +72,20 @@ const Card = React.forwardRef(
|
|||||||
checklists,
|
checklists,
|
||||||
watched,
|
watched,
|
||||||
members,
|
members,
|
||||||
|
labelVariant,
|
||||||
onCardMemberClick,
|
onCardMemberClick,
|
||||||
editable,
|
editable,
|
||||||
|
onCardLabelClick,
|
||||||
onEditCard,
|
onEditCard,
|
||||||
onCardTitleChange,
|
onCardTitleChange,
|
||||||
}: Props,
|
}: Props,
|
||||||
$cardRef: any,
|
$cardRef: any,
|
||||||
) => {
|
) => {
|
||||||
const [currentCardTitle, setCardTitle] = useState(title);
|
const [currentCardTitle, setCardTitle] = useState(title);
|
||||||
|
const [toggleLabels, setToggleLabels] = useState(false);
|
||||||
|
const [toggleDirection, setToggleDirection] = useState<'shrink' | 'expand'>(
|
||||||
|
labelVariant === 'large' ? 'shrink' : 'expand',
|
||||||
|
);
|
||||||
const $editorRef: any = useRef();
|
const $editorRef: any = useRef();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -132,21 +141,39 @@ const Card = React.forwardRef(
|
|||||||
>
|
>
|
||||||
<ListCardInnerContainer ref={$innerCardRef}>
|
<ListCardInnerContainer ref={$innerCardRef}>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<ListCardOperation onClick={e => {
|
<ListCardOperation
|
||||||
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onContextMenu) {
|
if (onContextMenu) {
|
||||||
onContextMenu($innerCardRef, taskID, taskGroupID);
|
onContextMenu($innerCardRef, taskID, taskGroupID);
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
||||||
</ListCardOperation>
|
</ListCardOperation>
|
||||||
)}
|
)}
|
||||||
<ListCardDetails complete={complete ?? false}>
|
<ListCardDetails complete={complete ?? false}>
|
||||||
<ListCardLabels>
|
<ListCardLabels
|
||||||
|
toggleLabels={toggleLabels}
|
||||||
|
toggleDirection={toggleDirection}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onCardLabelClick) {
|
||||||
|
setToggleLabels(true);
|
||||||
|
setToggleDirection(labelVariant === 'large' ? 'shrink' : 'expand');
|
||||||
|
onCardLabelClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{labels &&
|
{labels &&
|
||||||
labels.map(label => (
|
labels.map(label => (
|
||||||
<ListCardLabel color={label.labelColor.colorHex} key={label.id}>
|
<ListCardLabel
|
||||||
{label.name}
|
onAnimationEnd={() => setToggleLabels(false)}
|
||||||
|
variant={labelVariant ?? 'large'}
|
||||||
|
color={label.labelColor.colorHex}
|
||||||
|
key={label.id}
|
||||||
|
>
|
||||||
|
<ListCardLabelText>{label.name}</ListCardLabelText>
|
||||||
</ListCardLabel>
|
</ListCardLabel>
|
||||||
))}
|
))}
|
||||||
</ListCardLabels>
|
</ListCardLabels>
|
||||||
|
@ -17,7 +17,7 @@ import {
|
|||||||
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { getYear, getMonth } from 'date-fns';
|
import { getYear, getMonth } from 'date-fns';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
|
||||||
type DueDateManagerProps = {
|
type DueDateManagerProps = {
|
||||||
task: Task;
|
task: Task;
|
||||||
@ -147,7 +147,7 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
'November',
|
'November',
|
||||||
'December',
|
'December',
|
||||||
];
|
];
|
||||||
const { register, handleSubmit, errors, setValue, setError, formState } = useForm<DueDateFormData>();
|
const { register, handleSubmit, errors, setValue, setError, formState, control } = useForm<DueDateFormData>();
|
||||||
const saveDueDate = (data: any) => {
|
const saveDueDate = (data: any) => {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
|
const newDate = moment(`${data.endDate} ${data.endTime}`, 'YYYY-MM-DD h:mm A');
|
||||||
@ -155,27 +155,16 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
onDueDateChange(task, newDate.toDate());
|
onDueDateChange(task, newDate.toDate());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
console.log(errors);
|
|
||||||
register({ name: 'endTime' }, { required: 'End time is required' });
|
|
||||||
useEffect(() => {
|
|
||||||
setValue('endTime', now.format('h:mm A'));
|
|
||||||
}, []);
|
|
||||||
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
|
const CustomTimeInput = forwardRef(({ value, onClick }: any, $ref: any) => {
|
||||||
return (
|
return (
|
||||||
<DueDateInput
|
<DueDateInput
|
||||||
id="endTime"
|
id="endTime"
|
||||||
name="endTime"
|
name="endTime"
|
||||||
ref={$ref}
|
ref={$ref}
|
||||||
onChange={e => {
|
|
||||||
console.log(`onCahnge ${e.currentTarget.value}`);
|
|
||||||
setTextEndTime(e.currentTarget.value);
|
|
||||||
setValue('endTime', e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
width="100%"
|
width="100%"
|
||||||
variant="alternate"
|
variant="alternate"
|
||||||
label="Date"
|
label="Date"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
value={value}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -190,24 +179,21 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
width="100%"
|
width="100%"
|
||||||
variant="alternate"
|
variant="alternate"
|
||||||
label="Date"
|
label="Date"
|
||||||
onChange={e => {
|
defaultValue={textStartDate}
|
||||||
setTextStartDate(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
value={textStartDate}
|
|
||||||
ref={register({
|
ref={register({
|
||||||
required: 'End date is required.',
|
required: 'End date is required.',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField>
|
<FormField>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="endTime"
|
||||||
|
render={({ onChange, onBlur, value }) => (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
selected={endTime}
|
onChange={onChange}
|
||||||
onChange={date => {
|
selected={value}
|
||||||
const changedDate = moment(date ?? new Date());
|
onBlur={onBlur}
|
||||||
console.log(`changed ${date}`);
|
|
||||||
setEndTime(changedDate.toDate());
|
|
||||||
setValue('endTime', changedDate.format('h:mm A'));
|
|
||||||
}}
|
|
||||||
showTimeSelect
|
showTimeSelect
|
||||||
showTimeSelectOnly
|
showTimeSelectOnly
|
||||||
timeIntervals={15}
|
timeIntervals={15}
|
||||||
@ -215,6 +201,8 @@ const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange,
|
|||||||
dateFormat="h:mm aa"
|
dateFormat="h:mm aa"
|
||||||
customInput={<CustomTimeInput />}
|
customInput={<CustomTimeInput />}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<DueDatePickerWrapper>
|
<DueDatePickerWrapper>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
|
@ -87,9 +87,8 @@ type InputProps = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
value?: string;
|
defaultValue?: string;
|
||||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Input = React.forwardRef(
|
const Input = React.forwardRef(
|
||||||
@ -102,40 +101,34 @@ const Input = React.forwardRef(
|
|||||||
placeholder,
|
placeholder,
|
||||||
icon,
|
icon,
|
||||||
name,
|
name,
|
||||||
onChange,
|
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
floatingLabel,
|
floatingLabel,
|
||||||
value: initialValue,
|
defaultValue,
|
||||||
id,
|
id,
|
||||||
}: InputProps,
|
}: InputProps,
|
||||||
$ref: any,
|
$ref: any,
|
||||||
) => {
|
) => {
|
||||||
const [value, setValue] = useState(initialValue ?? '');
|
const [hasValue, setHasValue] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
if (initialValue) {
|
|
||||||
setValue(initialValue);
|
|
||||||
}
|
|
||||||
}, [initialValue]);
|
|
||||||
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
|
const borderColor = variant === 'normal' ? 'rgba(0, 0, 0, 0.2)' : '#414561';
|
||||||
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
|
const focusBg = variant === 'normal' ? 'rgba(38, 44, 73, )' : 'rgba(16, 22, 58, 1)';
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setValue(e.currentTarget.value);
|
|
||||||
if (onChange) {
|
|
||||||
onChange(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<InputWrapper className={className} width={width}>
|
<InputWrapper className={className} width={width}>
|
||||||
<InputInput
|
<InputInput
|
||||||
hasValue={floatingLabel || value !== ''}
|
onChange={() => {
|
||||||
|
console.log(`change ${$ref}!`);
|
||||||
|
if ($ref && $ref.current) {
|
||||||
|
console.log(`value : ${$ref.current.value}`);
|
||||||
|
setHasValue(($ref.current.value !== '' || floatingLabel) ?? false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
hasValue={hasValue}
|
||||||
ref={$ref}
|
ref={$ref}
|
||||||
id={id}
|
id={id}
|
||||||
name={name}
|
name={name}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onChange={handleChange}
|
|
||||||
autoComplete={autocomplete ? 'on' : 'off'}
|
autoComplete={autocomplete ? 'on' : 'off'}
|
||||||
value={value}
|
defaultValue={defaultValue}
|
||||||
hasIcon={typeof icon !== 'undefined'}
|
hasIcon={typeof icon !== 'undefined'}
|
||||||
width={width}
|
width={width}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
@ -93,6 +93,8 @@ export const Default = () => {
|
|||||||
onTaskDrop={onCardDrop}
|
onTaskDrop={onCardDrop}
|
||||||
onTaskGroupDrop={onListDrop}
|
onTaskGroupDrop={onListDrop}
|
||||||
onChangeTaskGroupName={action('change group name')}
|
onChangeTaskGroupName={action('change group name')}
|
||||||
|
cardLabelVariant="large"
|
||||||
|
onCardLabelClick={action('label click')}
|
||||||
onCreateTaskGroup={action('create list')}
|
onCreateTaskGroup={action('create list')}
|
||||||
onExtraMenuOpen={action('extra menu open')}
|
onExtraMenuOpen={action('extra menu open')}
|
||||||
onCardMemberClick={action('card member click')}
|
onCardMemberClick={action('card member click')}
|
||||||
|
@ -26,17 +26,21 @@ interface SimpleProps {
|
|||||||
onCreateTaskGroup: (listName: string) => void;
|
onCreateTaskGroup: (listName: string) => void;
|
||||||
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||||
onCardMemberClick: OnCardMemberClick;
|
onCardMemberClick: OnCardMemberClick;
|
||||||
|
onCardLabelClick: () => void;
|
||||||
|
cardLabelVariant: CardLabelVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SimpleLists: React.FC<SimpleProps> = ({
|
const SimpleLists: React.FC<SimpleProps> = ({
|
||||||
taskGroups,
|
taskGroups,
|
||||||
onTaskDrop,
|
onTaskDrop,
|
||||||
onChangeTaskGroupName,
|
onChangeTaskGroupName,
|
||||||
|
onCardLabelClick,
|
||||||
onTaskGroupDrop,
|
onTaskGroupDrop,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onQuickEditorOpen,
|
onQuickEditorOpen,
|
||||||
onCreateTaskGroup,
|
onCreateTaskGroup,
|
||||||
|
cardLabelVariant,
|
||||||
onExtraMenuOpen,
|
onExtraMenuOpen,
|
||||||
onCardMemberClick,
|
onCardMemberClick,
|
||||||
}) => {
|
}) => {
|
||||||
@ -158,10 +162,12 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
|||||||
{taskProvided => {
|
{taskProvided => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
labelVariant={cardLabelVariant}
|
||||||
wrapperProps={{
|
wrapperProps={{
|
||||||
...taskProvided.draggableProps,
|
...taskProvided.draggableProps,
|
||||||
...taskProvided.dragHandleProps,
|
...taskProvided.dragHandleProps,
|
||||||
}}
|
}}
|
||||||
|
onCardLabelClick={onCardLabelClick}
|
||||||
ref={taskProvided.innerRef}
|
ref={taskProvided.innerRef}
|
||||||
taskID={task.id}
|
taskID={task.id}
|
||||||
complete={task.complete ?? false}
|
complete={task.complete ?? false}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Checkmark } from 'shared/icons';
|
import { Checkmark } from 'shared/icons';
|
||||||
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
||||||
|
|
||||||
const WhiteCheckmark = styled(Checkmark)`
|
const WhiteCheckmark = styled(Checkmark)`
|
||||||
fill: rgba(${props => props.theme.colors.text.secondary});
|
fill: rgba(${props => props.theme.colors.text.secondary});
|
||||||
@ -39,7 +39,9 @@ const LabelManager = ({ labelColors, label, onLabelEdit, onLabelDelete }: Props)
|
|||||||
/>
|
/>
|
||||||
<FieldLabel>Select a color</FieldLabel>
|
<FieldLabel>Select a color</FieldLabel>
|
||||||
<div>
|
<div>
|
||||||
{labelColors.map((labelColor: LabelColor) => (
|
{labelColors
|
||||||
|
.filter(l => l.name !== 'no_color')
|
||||||
|
.map((labelColor: LabelColor) => (
|
||||||
<LabelBox
|
<LabelBox
|
||||||
key={labelColor.id}
|
key={labelColor.id}
|
||||||
color={labelColor.colorHex}
|
color={labelColor.colorHex}
|
||||||
|
@ -74,6 +74,7 @@ const ConfirmSubTitle = styled.h3`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const ConfirmDescription = styled.div`
|
const ConfirmDescription = styled.div`
|
||||||
|
margin: 0 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -83,7 +84,7 @@ const DeleteList = styled.ul`
|
|||||||
const DeleteListItem = styled.li`
|
const DeleteListItem = styled.li`
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
list-style: disc;
|
list-style: disc;
|
||||||
margin-left: 12px;
|
margin-left: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ConfirmDeleteButton = styled(Button)`
|
const ConfirmDeleteButton = styled(Button)`
|
||||||
|
103
frontend/src/shared/components/Register/Styles.ts
Normal file
103
frontend/src/shared/components/Register/Styles.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
import Button from 'shared/components/Button';
|
||||||
|
|
||||||
|
export const Wrapper = styled.div`
|
||||||
|
background: #eff2f7;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Column = styled.div`
|
||||||
|
width: 50%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LoginFormWrapper = styled.div`
|
||||||
|
background: #10163a;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LoginFormContainer = styled.div`
|
||||||
|
min-height: 505px;
|
||||||
|
padding: 2rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Title = styled.h1`
|
||||||
|
color: #ebeefd;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SubTitle = styled.h2`
|
||||||
|
color: #c2c6dc;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
`;
|
||||||
|
export const Form = styled.form`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FormLabel = styled.label`
|
||||||
|
color: #c2c6dc;
|
||||||
|
font-size: 12px;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FormTextInput = styled.input`
|
||||||
|
width: 100%;
|
||||||
|
background: #262c49;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 0.7rem 1rem 0.7rem 3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #c2c6dc;
|
||||||
|
border-radius: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FormIcon = styled.div`
|
||||||
|
top: 30px;
|
||||||
|
left: 16px;
|
||||||
|
position: absolute;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FormError = styled.span`
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgb(234, 84, 85);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LoginButton = styled(Button)``;
|
||||||
|
|
||||||
|
export const ActionButtons = styled.div`
|
||||||
|
margin-top: 17.5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const RegisterButton = styled(Button)``;
|
||||||
|
|
||||||
|
export const LogoTitle = styled.div`
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 12px;
|
||||||
|
transition: visibility, opacity, transform 0.25s ease;
|
||||||
|
color: #7367f0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const LogoWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: row;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: rgb(222, 235, 255);
|
||||||
|
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
|
||||||
|
`;
|
150
frontend/src/shared/components/Register/index.tsx
Normal file
150
frontend/src/shared/components/Register/index.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import AccessAccount from 'shared/undraw/AccessAccount';
|
||||||
|
import { User, Lock, Citadel } from 'shared/icons';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
LogoWrapper,
|
||||||
|
LogoTitle,
|
||||||
|
ActionButtons,
|
||||||
|
RegisterButton,
|
||||||
|
FormError,
|
||||||
|
FormIcon,
|
||||||
|
FormLabel,
|
||||||
|
FormTextInput,
|
||||||
|
Wrapper,
|
||||||
|
Column,
|
||||||
|
LoginFormWrapper,
|
||||||
|
LoginFormContainer,
|
||||||
|
Title,
|
||||||
|
SubTitle,
|
||||||
|
} from './Styles';
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
|
||||||
|
const INITIALS_PATTERN = /[a-zA-Z]{2,3}/i;
|
||||||
|
|
||||||
|
const Register = ({ onSubmit }: RegisterProps) => {
|
||||||
|
const [isComplete, setComplete] = useState(true);
|
||||||
|
const { register, handleSubmit, errors, setError } = useForm<RegisterFormData>();
|
||||||
|
const loginSubmit = (data: RegisterFormData) => {
|
||||||
|
setComplete(false);
|
||||||
|
onSubmit(data, setComplete, setError);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Wrapper>
|
||||||
|
<Column>
|
||||||
|
<AccessAccount width={275} height={250} />
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<LoginFormWrapper>
|
||||||
|
<LoginFormContainer>
|
||||||
|
<LogoWrapper>
|
||||||
|
<Citadel width={42} height={42} />
|
||||||
|
<LogoTitle>Citadel</LogoTitle>
|
||||||
|
</LogoWrapper>
|
||||||
|
<Title>Register</Title>
|
||||||
|
<SubTitle>Please create the system admin user</SubTitle>
|
||||||
|
<Form onSubmit={handleSubmit(loginSubmit)}>
|
||||||
|
<FormLabel htmlFor="fullname">
|
||||||
|
Full name
|
||||||
|
<FormTextInput
|
||||||
|
type="text"
|
||||||
|
id="fullname"
|
||||||
|
name="fullname"
|
||||||
|
ref={register({ required: 'Full name is required' })}
|
||||||
|
/>
|
||||||
|
<FormIcon>
|
||||||
|
<User color="#c2c6dc" size={20} />
|
||||||
|
</FormIcon>
|
||||||
|
</FormLabel>
|
||||||
|
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||||
|
<FormLabel htmlFor="username">
|
||||||
|
Username
|
||||||
|
<FormTextInput
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
ref={register({ required: 'Username is required' })}
|
||||||
|
/>
|
||||||
|
<FormIcon>
|
||||||
|
<User color="#c2c6dc" size={20} />
|
||||||
|
</FormIcon>
|
||||||
|
</FormLabel>
|
||||||
|
{errors.username && <FormError>{errors.username.message}</FormError>}
|
||||||
|
<FormLabel htmlFor="email">
|
||||||
|
Email
|
||||||
|
<FormTextInput
|
||||||
|
type="text"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
ref={register({
|
||||||
|
required: 'Email is required',
|
||||||
|
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<FormIcon>
|
||||||
|
<User color="#c2c6dc" size={20} />
|
||||||
|
</FormIcon>
|
||||||
|
</FormLabel>
|
||||||
|
{errors.email && <FormError>{errors.email.message}</FormError>}
|
||||||
|
<FormLabel htmlFor="initials">
|
||||||
|
Initials
|
||||||
|
<FormTextInput
|
||||||
|
type="text"
|
||||||
|
id="initials"
|
||||||
|
name="initials"
|
||||||
|
ref={register({
|
||||||
|
required: 'Initials is required',
|
||||||
|
pattern: {
|
||||||
|
value: INITIALS_PATTERN,
|
||||||
|
message: 'Initials must be between 2 to 3 characters.',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<FormIcon>
|
||||||
|
<User color="#c2c6dc" size={20} />
|
||||||
|
</FormIcon>
|
||||||
|
</FormLabel>
|
||||||
|
{errors.initials && <FormError>{errors.initials.message}</FormError>}
|
||||||
|
<FormLabel htmlFor="password">
|
||||||
|
Password
|
||||||
|
<FormTextInput
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
ref={register({ required: 'Password is required' })}
|
||||||
|
/>
|
||||||
|
<FormIcon>
|
||||||
|
<Lock width={20} height={20} />
|
||||||
|
</FormIcon>
|
||||||
|
</FormLabel>
|
||||||
|
{errors.password && <FormError>{errors.password.message}</FormError>}
|
||||||
|
<FormLabel htmlFor="password_confirm">
|
||||||
|
Password (Confirm)
|
||||||
|
<FormTextInput
|
||||||
|
type="password"
|
||||||
|
id="password_confirm"
|
||||||
|
name="password_confirm"
|
||||||
|
ref={register({ required: 'Password (confirm) is required' })}
|
||||||
|
/>
|
||||||
|
<FormIcon>
|
||||||
|
<Lock width={20} height={20} />
|
||||||
|
</FormIcon>
|
||||||
|
</FormLabel>
|
||||||
|
{errors.password_confirm && <FormError>{errors.password_confirm.message}</FormError>}
|
||||||
|
|
||||||
|
<ActionButtons>
|
||||||
|
<RegisterButton type="submit" disabled={!isComplete}>
|
||||||
|
Register
|
||||||
|
</RegisterButton>
|
||||||
|
</ActionButtons>
|
||||||
|
</Form>
|
||||||
|
</LoginFormContainer>
|
||||||
|
</LoginFormWrapper>
|
||||||
|
</Column>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
@ -263,13 +263,13 @@ const Settings: React.FC<SettingsProps> = ({ onProfileAvatarRemove, onProfileAva
|
|||||||
onProfileAvatarChange={onProfileAvatarChange}
|
onProfileAvatarChange={onProfileAvatarChange}
|
||||||
profile={profile.profileIcon}
|
profile={profile.profileIcon}
|
||||||
/>
|
/>
|
||||||
<Input value={profile.fullName} width="100%" label="Name" />
|
<Input defaultValue={profile.fullName} width="100%" label="Name" />
|
||||||
<Input
|
<Input
|
||||||
value={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
||||||
width="100%"
|
width="100%"
|
||||||
label="Initials "
|
label="Initials "
|
||||||
/>
|
/>
|
||||||
<Input value={profile.username ?? ''} width="100%" label="Username " />
|
<Input defaultValue={profile.username ?? ''} width="100%" label="Username " />
|
||||||
<Input width="100%" label="Email" />
|
<Input width="100%" label="Email" />
|
||||||
<Input width="100%" label="Bio" />
|
<Input width="100%" label="Bio" />
|
||||||
<SettingActions>
|
<SettingActions>
|
||||||
|
@ -267,6 +267,7 @@ export type Mutation = {
|
|||||||
updateTaskLocation: UpdateTaskLocationPayload;
|
updateTaskLocation: UpdateTaskLocationPayload;
|
||||||
updateTaskName: Task;
|
updateTaskName: Task;
|
||||||
updateTeamMemberRole: UpdateTeamMemberRolePayload;
|
updateTeamMemberRole: UpdateTeamMemberRolePayload;
|
||||||
|
updateUserPassword: UpdateUserPasswordPayload;
|
||||||
updateUserRole: UpdateUserRolePayload;
|
updateUserRole: UpdateUserRolePayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -506,6 +507,11 @@ export type MutationUpdateTeamMemberRoleArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationUpdateUserPasswordArgs = {
|
||||||
|
input: UpdateUserPassword;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationUpdateUserRoleArgs = {
|
export type MutationUpdateUserRoleArgs = {
|
||||||
input: UpdateUserRole;
|
input: UpdateUserRole;
|
||||||
};
|
};
|
||||||
@ -862,6 +868,17 @@ export type SetTeamOwnerPayload = {
|
|||||||
newOwner: Member;
|
newOwner: Member;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateUserPassword = {
|
||||||
|
userID: Scalars['UUID'];
|
||||||
|
password: Scalars['String'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateUserPasswordPayload = {
|
||||||
|
__typename?: 'UpdateUserPasswordPayload';
|
||||||
|
ok: Scalars['Boolean'];
|
||||||
|
user: UserAccount;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateUserRole = {
|
export type UpdateUserRole = {
|
||||||
userID: Scalars['UUID'];
|
userID: Scalars['UUID'];
|
||||||
roleCode: RoleCode;
|
roleCode: RoleCode;
|
||||||
|
@ -13581,10 +13581,10 @@ react-helmet-async@^1.0.2:
|
|||||||
react-fast-compare "^2.0.4"
|
react-fast-compare "^2.0.4"
|
||||||
shallowequal "^1.1.0"
|
shallowequal "^1.1.0"
|
||||||
|
|
||||||
react-hook-form@^5.2.0:
|
react-hook-form@^6.0.6:
|
||||||
version "5.2.0"
|
version "6.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-5.2.0.tgz#b5b654516ee03d55d78b7b9e194c7f4632885426"
|
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.0.6.tgz#72ac1668aeaddfd642bcfb324cebe1ba237fb13e"
|
||||||
integrity sha512-EqGCSl3DxSUBtL/9lFvrFQLJ7ICdVKrfjcMHay2SvmU4trR8aqrd7YuiLSojBKmZBRdBnCcxG+LzLWF9z474NA==
|
integrity sha512-qxWhV++1V7SKKlr2hHFsessGwATCdexgVsByxOHltDyO9F0VWB1WN4ZvxnKuHTVGwjj6CLZo0mL+Hgy0QH1sAw==
|
||||||
|
|
||||||
react-hotkeys@2.0.0:
|
react-hotkeys@2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
|
@ -9,8 +9,16 @@ import (
|
|||||||
|
|
||||||
var jwtKey = []byte("citadel_test_key")
|
var jwtKey = []byte("citadel_test_key")
|
||||||
|
|
||||||
|
type RestrictedMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Unrestricted RestrictedMode = "unrestricted"
|
||||||
|
InstallOnly = "install_only"
|
||||||
|
)
|
||||||
|
|
||||||
type AccessTokenClaims struct {
|
type AccessTokenClaims struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
|
Restricted RestrictedMode `json:"restricted"`
|
||||||
jwt.StandardClaims
|
jwt.StandardClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,10 +39,11 @@ func (r *ErrMalformedToken) Error() string {
|
|||||||
return "token is malformed"
|
return "token is malformed"
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccessToken(userID string) (string, error) {
|
func NewAccessToken(userID string, restrictedMode RestrictedMode) (string, error) {
|
||||||
accessExpirationTime := time.Now().Add(5 * time.Second)
|
accessExpirationTime := time.Now().Add(5 * time.Second)
|
||||||
accessClaims := &AccessTokenClaims{
|
accessClaims := &AccessTokenClaims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
Restricted: restrictedMode,
|
||||||
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +59,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
|
|||||||
accessExpirationTime := time.Now().Add(dur)
|
accessExpirationTime := time.Now().Add(dur)
|
||||||
accessClaims := &AccessTokenClaims{
|
accessClaims := &AccessTokenClaims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
Restricted: Unrestricted,
|
||||||
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,6 +58,12 @@ type Role struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemOption struct {
|
||||||
|
OptionID uuid.UUID `json:"option_id"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value sql.NullString `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
TaskID uuid.UUID `json:"task_id"`
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
TaskGroupID uuid.UUID `json:"task_group_id"`
|
TaskGroupID uuid.UUID `json:"task_group_id"`
|
||||||
|
@ -15,6 +15,7 @@ type Querier interface {
|
|||||||
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
|
CreateProjectLabel(ctx context.Context, arg CreateProjectLabelParams) (ProjectLabel, error)
|
||||||
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
|
CreateProjectMember(ctx context.Context, arg CreateProjectMemberParams) (ProjectMember, error)
|
||||||
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
|
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
|
||||||
|
CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error)
|
||||||
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
|
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
|
||||||
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
|
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
|
||||||
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
|
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
|
||||||
@ -61,6 +62,7 @@ type Querier interface {
|
|||||||
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
|
GetRoleForProjectMemberByUserID(ctx context.Context, arg GetRoleForProjectMemberByUserIDParams) (Role, error)
|
||||||
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
|
GetRoleForTeamMember(ctx context.Context, arg GetRoleForTeamMemberParams) (Role, error)
|
||||||
GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error)
|
GetRoleForUserID(ctx context.Context, userID uuid.UUID) (GetRoleForUserIDRow, error)
|
||||||
|
GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error)
|
||||||
GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error)
|
GetTaskByID(ctx context.Context, taskID uuid.UUID) (Task, error)
|
||||||
GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error)
|
GetTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) (TaskChecklist, error)
|
||||||
GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error)
|
GetTaskChecklistItemByID(ctx context.Context, taskChecklistItemID uuid.UUID) (TaskChecklistItem, error)
|
||||||
|
5
internal/db/query/system_options.sql
Normal file
5
internal/db/query/system_options.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- name: GetSystemOptionByKey :one
|
||||||
|
SELECT key, value FROM system_options WHERE key = $1;
|
||||||
|
|
||||||
|
-- name: CreateSystemOption :one
|
||||||
|
INSERT INTO system_options (key, value) VALUES ($1, $2) RETURNING *;
|
41
internal/db/system_options.sql.go
Normal file
41
internal/db/system_options.sql.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// source: system_options.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createSystemOption = `-- name: CreateSystemOption :one
|
||||||
|
INSERT INTO system_options (key, value) VALUES ($1, $2) RETURNING option_id, key, value
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateSystemOptionParams struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value sql.NullString `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateSystemOption(ctx context.Context, arg CreateSystemOptionParams) (SystemOption, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createSystemOption, arg.Key, arg.Value)
|
||||||
|
var i SystemOption
|
||||||
|
err := row.Scan(&i.OptionID, &i.Key, &i.Value)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSystemOptionByKey = `-- name: GetSystemOptionByKey :one
|
||||||
|
SELECT key, value FROM system_options WHERE key = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSystemOptionByKeyRow struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value sql.NullString `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSystemOptionByKey(ctx context.Context, key string) (GetSystemOptionByKeyRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getSystemOptionByKey, key)
|
||||||
|
var i GetSystemOptionByKeyRow
|
||||||
|
err := row.Scan(&i.Key, &i.Value)
|
||||||
|
return i, err
|
||||||
|
}
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/99designs/gqlgen/graphql/handler/transport"
|
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||||
"github.com/99designs/gqlgen/graphql/playground"
|
"github.com/99designs/gqlgen/graphql/playground"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jordanknott/project-citadel/api/internal/auth"
|
||||||
"github.com/jordanknott/project-citadel/api/internal/config"
|
"github.com/jordanknott/project-citadel/api/internal/config"
|
||||||
"github.com/jordanknott/project-citadel/api/internal/db"
|
"github.com/jordanknott/project-citadel/api/internal/db"
|
||||||
)
|
)
|
||||||
@ -49,7 +50,13 @@ func NewHandler(config config.AppConfig, repo db.Repository) http.Handler {
|
|||||||
func NewPlaygroundHandler(endpoint string) http.Handler {
|
func NewPlaygroundHandler(endpoint string) http.Handler {
|
||||||
return playground.Handler("GraphQL Playground", endpoint)
|
return playground.Handler("GraphQL Playground", endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
|
func GetUserID(ctx context.Context) (uuid.UUID, bool) {
|
||||||
userID, ok := ctx.Value("userID").(uuid.UUID)
|
userID, ok := ctx.Value("userID").(uuid.UUID)
|
||||||
return userID, ok
|
return userID, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetRestrictedMode(ctx context.Context) (auth.RestrictedMode, bool) {
|
||||||
|
restricted, ok := ctx.Value("restricted_mode").(auth.RestrictedMode)
|
||||||
|
return restricted, ok
|
||||||
|
}
|
||||||
|
@ -706,6 +706,7 @@ func (r *mutationResolver) DeleteTeamMember(ctx context.Context, input DeleteTea
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return &DeleteTeamMemberPayload{}, err
|
return &DeleteTeamMemberPayload{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID})
|
_, err = r.Repository.GetTeamMemberByID(ctx, db.GetTeamMemberByIDParams{TeamID: input.TeamID, UserID: input.UserID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &DeleteTeamMemberPayload{}, err
|
return &DeleteTeamMemberPayload{}, err
|
||||||
@ -992,7 +993,10 @@ func (r *queryResolver) Me(ctx context.Context) (*db.UserAccount, error) {
|
|||||||
return &db.UserAccount{}, fmt.Errorf("internal server error")
|
return &db.UserAccount{}, fmt.Errorf("internal server error")
|
||||||
}
|
}
|
||||||
user, err := r.Repository.GetUserAccountByID(ctx, userID)
|
user, err := r.Repository.GetUserAccountByID(ctx, userID)
|
||||||
if err != nil {
|
if err == sql.ErrNoRows {
|
||||||
|
log.WithFields(log.Fields{"userID": userID}).Warning("can not find user for me query")
|
||||||
|
return &db.UserAccount{}, nil
|
||||||
|
} else if err != nil {
|
||||||
return &db.UserAccount{}, err
|
return &db.UserAccount{}, err
|
||||||
}
|
}
|
||||||
return &user, err
|
return &user, err
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
package route
|
package route
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dgrijalva/jwt-go"
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jordanknott/project-citadel/api/internal/auth"
|
"github.com/jordanknott/project-citadel/api/internal/auth"
|
||||||
@ -18,23 +18,26 @@ var jwtKey = []byte("citadel_test_key")
|
|||||||
|
|
||||||
type authResource struct{}
|
type authResource struct{}
|
||||||
|
|
||||||
type AccessTokenClaims struct {
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
jwt.StandardClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
type RefreshTokenClaims struct {
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
jwt.StandardClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginRequestData struct {
|
type LoginRequestData struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NewUserAccount struct {
|
||||||
|
FullName string `json:"fullname"`
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Initials string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstallRequestData struct {
|
||||||
|
User NewUserAccount
|
||||||
|
}
|
||||||
|
|
||||||
type LoginResponseData struct {
|
type LoginResponseData struct {
|
||||||
AccessToken string `json:"accessToken"`
|
AccessToken string `json:"accessToken"`
|
||||||
|
IsInstalled bool `json:"isInstalled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogoutResponseData struct {
|
type LogoutResponseData struct {
|
||||||
@ -51,18 +54,48 @@ type AvatarUploadResponseData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
user, err := h.repo.GetUserAccountByUsername(r.Context(), "system")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: false})
|
||||||
|
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
log.WithError(err).Error("get system option")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c, err := r.Cookie("refreshToken")
|
c, err := r.Cookie("refreshToken")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == http.ErrNoCookie {
|
if err == http.ErrNoCookie {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.WithError(err).Error("unknown error")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refreshTokenID := uuid.MustParse(c.Value)
|
refreshTokenID := uuid.MustParse(c.Value)
|
||||||
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
|
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
|
||||||
|
log.WithError(err).WithFields(log.Fields{"refreshTokenID": refreshTokenID.String()}).Error("no tokens found")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.WithError(err).Error("token retrieve failure")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -76,7 +109,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
|
|||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
accessTokenString, err := auth.NewAccessToken(token.UserID.String())
|
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@ -88,7 +121,7 @@ func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Requ
|
|||||||
Expires: refreshExpiresAt,
|
Expires: refreshExpiresAt,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
})
|
})
|
||||||
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString})
|
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *CitadelHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
func (h *CitadelHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -142,7 +175,7 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||||
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||||
|
|
||||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String())
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@ -154,7 +187,68 @@ func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Expires: refreshExpiresAt,
|
Expires: refreshExpiresAt,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
})
|
})
|
||||||
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString})
|
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CitadelHandler) InstallHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok {
|
||||||
|
if restricted != auth.InstallOnly {
|
||||||
|
log.Warning("attempted to install without install only restriction")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
log.WithError(err).Error("install handler called even though system is installed")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestData InstallRequestData
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&requestData)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.WithFields(log.Fields{"r": requestData}).Info("install")
|
||||||
|
|
||||||
|
createdAt := time.Now().UTC()
|
||||||
|
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
|
||||||
|
user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{
|
||||||
|
Username: requestData.User.Username,
|
||||||
|
Initials: requestData.User.Initials,
|
||||||
|
Email: requestData.User.Email,
|
||||||
|
PasswordHash: string(hashedPwd),
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
RoleCode: "admin",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = h.repo.CreateSystemOption(r.Context(), db.CreateSystemOptionParams{Key: "is_installed", Value: sql.NullString{Valid: true, String: "true"}})
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCreatedAt := time.Now().UTC()
|
||||||
|
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
|
||||||
|
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
|
||||||
|
|
||||||
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-type", "application/json")
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "refreshToken",
|
||||||
|
Value: refreshTokenString.TokenID.String(),
|
||||||
|
Expires: refreshExpiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router {
|
func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router {
|
||||||
|
@ -40,13 +40,19 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := uuid.Parse(accessClaims.UserID)
|
var userID uuid.UUID
|
||||||
|
if accessClaims.Restricted == auth.InstallOnly {
|
||||||
|
userID = uuid.New()
|
||||||
|
} else {
|
||||||
|
userID, err = uuid.Parse(accessClaims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.WithError(err).Error("middleware access token userID parse")
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ctx := context.WithValue(r.Context(), "userID", userID)
|
ctx := context.WithValue(r.Context(), "userID", userID)
|
||||||
|
ctx = context.WithValue(ctx, "restricted_mode", accessClaims.Restricted)
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
|
@ -104,6 +104,7 @@ func NewRouter(config config.AppConfig, dbConnection *sqlx.DB) (chi.Router, erro
|
|||||||
r.Group(func(mux chi.Router) {
|
r.Group(func(mux chi.Router) {
|
||||||
mux.Use(AuthenticationMiddleware)
|
mux.Use(AuthenticationMiddleware)
|
||||||
mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload)
|
mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload)
|
||||||
|
mux.Post("/auth/install", citadelHandler.InstallHandler)
|
||||||
mux.Handle("/graphql", graph.NewHandler(config, *repository))
|
mux.Handle("/graphql", graph.NewHandler(config, *repository))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
5
migrations/0045_add-system_options-table.up.sql
Normal file
5
migrations/0045_add-system_options-table.up.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE system_options (
|
||||||
|
option_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
key text NOT NULL UNIQUE,
|
||||||
|
value text
|
||||||
|
);
|
2
migrations/0046_add-system-user.up.sql
Normal file
2
migrations/0046_add-system-user.up.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
INSERT INTO user_account(created_at, email, initials, username, full_name,
|
||||||
|
role_code, password_hash) VALUES (NOW(), '', 'SYS', 'system', 'System', 'owner', '');
|
16
migrations/0047_add-default-label_colors.up.sql
Normal file
16
migrations/0047_add-default-label_colors.up.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( 'transparent', 0.0, 'no_color' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#e8384f', 1.0, 'red' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fd612c', 2.0, 'orange' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fd9a00', 3.0, 'yellow_orange' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#eec300', 4.0, 'yellow' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#a4cf30', 5.0, 'yellow_green' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#62d26f', 6.0, 'green' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#37c5ab', 6.0, 'blue_green' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#20aaea', 6.0, 'aqua' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#4186e0', 6.0, 'blue' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#7a6ff0', 6.0, 'indigo' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#aa62e3', 6.0, 'purple' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#e362e3', 6.0, 'magenta' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#ea4e9d', 6.0, 'hot_pink' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#fc91ad', 6.0, 'pink' );
|
||||||
|
INSERT INTO label_color (color_hex, position, name ) VALUES ( '#8da3a6', 6.0, 'cool_gray' );
|
@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE task_group DROP CONSTRAINT task_group_project_id_fkey;
|
||||||
|
ALTER TABLE task_group
|
||||||
|
ADD CONSTRAINT task_group_project_id_fkey
|
||||||
|
FOREIGN KEY (project_id)
|
||||||
|
REFERENCES project(project_id)
|
||||||
|
ON DELETE CASCADE;
|
@ -0,0 +1,27 @@
|
|||||||
|
+--------------------+--------------------------+--------------------------------------+
|
||||||
|
| [38;5;47;01mColumn[39;00m | [38;5;47;01mType[39;00m | [38;5;47;01mModifiers[39;00m |
|
||||||
|
|--------------------+--------------------------+--------------------------------------|
|
||||||
|
| user_id | uuid | not null default uuid_generate_v4() |
|
||||||
|
| created_at | timestamp with time zone | not null |
|
||||||
|
| email | text | not null |
|
||||||
|
| username | text | not null |
|
||||||
|
| password_hash | text | not null |
|
||||||
|
| profile_bg_color | text | not null default '#7367F0'::text |
|
||||||
|
| full_name | text | not null |
|
||||||
|
| initials | text | not null default ''::text |
|
||||||
|
| profile_avatar_url | text | |
|
||||||
|
| role_code | text | not null default 'member'::text |
|
||||||
|
+--------------------+--------------------------+--------------------------------------+
|
||||||
|
Indexes:
|
||||||
|
"user_account_pkey" PRIMARY KEY, btree (user_id)
|
||||||
|
"user_account_email_key" UNIQUE CONSTRAINT, btree (email)
|
||||||
|
"user_account_username_key" UNIQUE CONSTRAINT, btree (username)
|
||||||
|
Foreign-key constraints:
|
||||||
|
"user_account_role_code_fkey" FOREIGN KEY (role_code) REFERENCES role(code) ON DELETE CASCADE
|
||||||
|
Referenced by:
|
||||||
|
TABLE "refresh_token" CONSTRAINT "refresh_token_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE
|
||||||
|
TABLE "team" CONSTRAINT "team_owner_fkey" FOREIGN KEY (owner) REFERENCES user_account(user_id) ON DELETE CASCADE
|
||||||
|
TABLE "task_assigned" CONSTRAINT "task_assigned_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE
|
||||||
|
TABLE "team_member" CONSTRAINT "team_member_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE
|
||||||
|
TABLE "project_member" CONSTRAINT "project_member_user_id_fkey" FOREIGN KEY (user_id) REFERENCES user_account(user_id) ON DELETE CASCADE
|
||||||
|
|
Loading…
Reference in New Issue
Block a user