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",
|
||||
|
@ -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';
|
||||
|
||||
@ -26,6 +27,7 @@ type 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,6 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import jwtDecode from 'jwt-decode';
|
||||
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';
|
||||
@ -9,10 +11,12 @@ import {theme} from './ThemeStyles';
|
||||
import Routes from './Routes';
|
||||
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);
|
||||
@ -28,10 +32,13 @@ const App = () => {
|
||||
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);
|
||||
});
|
||||
|
@ -16,7 +16,7 @@ const Auth = () => {
|
||||
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,8 +28,8 @@ 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();
|
||||
|
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 styled, { css } from 'styled-components/macro';
|
||||
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 { useParams, Route, useRouteMatch, useHistory, RouteComponentProps, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
@ -103,9 +103,12 @@ 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);
|
||||
@ -156,7 +159,8 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({projectID}) => {
|
||||
updateApolloCache<FindProjectQuery>(
|
||||
client,
|
||||
FindProjectDocument,
|
||||
cache => produce(cache, draftCache => {
|
||||
cache =>
|
||||
produce(cache, draftCache => {
|
||||
draftCache.findProject.taskGroups.push({ ...newTaskGroupData.data.createTaskGroup, tasks: [] });
|
||||
}),
|
||||
{ projectId: projectID },
|
||||
@ -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: {
|
||||
|
@ -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) => {
|
||||
|
@ -4,8 +4,16 @@ import updateApolloCache from 'shared/utils/cache';
|
||||
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 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;
|
||||
@ -110,6 +130,8 @@ const Project = () => {
|
||||
console.log(taskLabelsRef.current);
|
||||
},
|
||||
});
|
||||
|
||||
const [value, setValue] = useStateWithLocalStorage(CARD_LABEL_VARIANT_STORAGE_KEY);
|
||||
const [updateProjectMemberRole] = useUpdateProjectMemberRoleMutation();
|
||||
|
||||
const [deleteTask] = useDeleteTaskMutation();
|
||||
@ -224,17 +246,18 @@ const Project = () => {
|
||||
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
|
||||
|
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';
|
||||
|
@ -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,11 +258,11 @@ 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>
|
||||
</>
|
||||
@ -251,11 +271,11 @@ const TeamRoleManagerPopup: React.FC<TeamRoleManagerPopupProps> = ({
|
||||
const UserSelect = styled(Select)`
|
||||
margin: 8px 0;
|
||||
padding: 8px 0;
|
||||
`
|
||||
`;
|
||||
|
||||
const NewUserPassInput = styled(Input)`
|
||||
margin: 8px 0;
|
||||
`
|
||||
`;
|
||||
const InviteMemberButton = styled(Button)`
|
||||
padding: 7px 12px;
|
||||
`;
|
||||
@ -263,11 +283,11 @@ const InviteMemberButton = styled(Button)`
|
||||
const UserPassBar = styled.div`
|
||||
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``;
|
||||
|
||||
@ -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}>
|
||||
@ -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,4 +1,4 @@
|
||||
import styled, {css} from 'styled-components';
|
||||
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';
|
||||
@ -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)};
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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)`
|
||||
|
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