Compare commits
10 Commits
feat/updat
...
0.2.2
Author | SHA1 | Date | |
---|---|---|---|
c7538a98e5 | |||
fe84f97f18 | |||
52c60abcd7 | |||
9fdb3008db | |||
e2ef8a1a19 | |||
61cd376bfd | |||
ba9fc64fd9 | |||
03dafe9b7b | |||
12a767947a | |||
40557ba79f |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -18,6 +18,8 @@ If applicable, add screenshots to help explain your problem.
|
|||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here.
|
Add any other context about the problem here.
|
||||||
|
|
||||||
|
Please send the Taskcafe web service logs if applicable.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
||||||
Please read the contributing guide before working on any new pull requests!
|
Please read the contributing guide before working on any new pull requests!
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
[general]
|
[server]
|
||||||
host = '0.0.0.0:3333'
|
hostname = '0.0.0.0:3333'
|
||||||
|
|
||||||
[email_notifications]
|
[email_notifications]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
@ -6,14 +6,6 @@
|
|||||||
"@apollo/client": "^3.0.0-rc.8",
|
"@apollo/client": "^3.0.0-rc.8",
|
||||||
"@apollo/react-common": "^3.1.4",
|
"@apollo/react-common": "^3.1.4",
|
||||||
"@apollo/react-hooks": "^3.1.3",
|
"@apollo/react-hooks": "^3.1.3",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.27",
|
|
||||||
"@fortawesome/free-brands-svg-icons": "^5.12.1",
|
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.12.1",
|
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.12.1",
|
|
||||||
"@fortawesome/react-fontawesome": "^0.1.8",
|
|
||||||
"@testing-library/jest-dom": "^4.2.4",
|
|
||||||
"@testing-library/react": "^9.3.2",
|
|
||||||
"@testing-library/user-event": "^7.1.2",
|
|
||||||
"@types/axios": "^0.14.0",
|
"@types/axios": "^0.14.0",
|
||||||
"@types/color": "^3.0.1",
|
"@types/color": "^3.0.1",
|
||||||
"@types/date-fns": "^2.6.0",
|
"@types/date-fns": "^2.6.0",
|
||||||
|
@ -59,7 +59,7 @@ const Install = () => {
|
|||||||
} else {
|
} else {
|
||||||
const response: RefreshTokenResponse = await x.data;
|
const response: RefreshTokenResponse = await x.data;
|
||||||
const { accessToken: newToken, isInstalled } = response;
|
const { accessToken: newToken, isInstalled } = response;
|
||||||
const claims: JWTToken = jwtDecode(accessToken);
|
const claims: JWTToken = jwtDecode(newToken);
|
||||||
const currentUser = {
|
const currentUser = {
|
||||||
id: claims.userId,
|
id: claims.userId,
|
||||||
roles: {
|
roles: {
|
||||||
@ -69,7 +69,7 @@ const Install = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
setAccessToken(accessToken);
|
setAccessToken(newToken);
|
||||||
if (!isInstalled) {
|
if (!isInstalled) {
|
||||||
history.replace('/install');
|
history.replace('/install');
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,8 @@ import {
|
|||||||
useCreateProjectMutation,
|
useCreateProjectMutation,
|
||||||
GetProjectsDocument,
|
GetProjectsDocument,
|
||||||
GetProjectsQuery,
|
GetProjectsQuery,
|
||||||
|
MeQuery,
|
||||||
|
MeDocument,
|
||||||
} from 'shared/generated/graphql';
|
} from 'shared/generated/graphql';
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import styled, { css, keyframes } from 'styled-components';
|
import styled, { css, keyframes } from 'styled-components';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { mixin } from 'shared/utils/styles';
|
import { mixin } from 'shared/utils/styles';
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
import TextareaAutosize from 'react-autosize-textarea';
|
||||||
import { CheckCircle, CheckSquareOutline } from 'shared/icons';
|
import { CheckCircle, CheckSquareOutline, Clock } from 'shared/icons';
|
||||||
import { RefObject } from 'react';
|
|
||||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||||
|
|
||||||
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
|
export const CardMember = styled(TaskAssignee)<{ zIndex: number }>`
|
||||||
@ -20,7 +18,9 @@ export const ChecklistIcon = styled(CheckSquareOutline)<{ color: 'success' | 'no
|
|||||||
stroke: rgba(${props.theme.colors.success});
|
stroke: rgba(${props.theme.colors.success});
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
export const ClockIcon = styled(FontAwesomeIcon)``;
|
export const ClockIcon = styled(Clock)<{ color: string }>`
|
||||||
|
fill: ${props => props.color};
|
||||||
|
`;
|
||||||
|
|
||||||
export const EditorTextarea = styled(TextareaAutosize)`
|
export const EditorTextarea = styled(TextareaAutosize)`
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { Pencil, Eye, List } from 'shared/icons';
|
||||||
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { faClock, faEye } from '@fortawesome/free-regular-svg-icons';
|
|
||||||
import {
|
import {
|
||||||
EditorTextarea,
|
EditorTextarea,
|
||||||
CardMember,
|
CardMember,
|
||||||
@ -155,7 +153,7 @@ const Card = React.forwardRef(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
|
<Pencil width={8} height={8} />
|
||||||
</ListCardOperation>
|
</ListCardOperation>
|
||||||
)}
|
)}
|
||||||
<ListCardDetails complete={complete ?? false}>
|
<ListCardDetails complete={complete ?? false}>
|
||||||
@ -218,18 +216,18 @@ const Card = React.forwardRef(
|
|||||||
<ListCardBadges>
|
<ListCardBadges>
|
||||||
{watched && (
|
{watched && (
|
||||||
<ListCardBadge>
|
<ListCardBadge>
|
||||||
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
|
<Eye width={8} height={8} />
|
||||||
</ListCardBadge>
|
</ListCardBadge>
|
||||||
)}
|
)}
|
||||||
{dueDate && (
|
{dueDate && (
|
||||||
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
|
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
|
||||||
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
|
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} width={8} height={8} />
|
||||||
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
|
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
|
||||||
</DueDateCardBadge>
|
</DueDateCardBadge>
|
||||||
)}
|
)}
|
||||||
{description && (
|
{description && (
|
||||||
<DescriptionBadge>
|
<DescriptionBadge>
|
||||||
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
|
<List width={8} height={8} />
|
||||||
</DescriptionBadge>
|
</DescriptionBadge>
|
||||||
)}
|
)}
|
||||||
{checklists && (
|
{checklists && (
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Button from 'shared/components/Button';
|
import Button from 'shared/components/Button';
|
||||||
import TextareaAutosize from 'react-autosize-textarea';
|
import TextareaAutosize from 'react-autosize-textarea';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { mixin } from 'shared/utils/styles';
|
import { mixin } from 'shared/utils/styles';
|
||||||
|
|
||||||
export const CancelIcon = styled(FontAwesomeIcon)`
|
export const CancelIconWrapper = styled.div`
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
|
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
display: ${props => (props.isOpen ? 'flex' : 'none')};
|
display: ${props => (props.isOpen ? 'flex' : 'none')};
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
|
||||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||||
|
import { Cross } from 'shared/icons';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CardComposerWrapper,
|
CardComposerWrapper,
|
||||||
CancelIcon,
|
CancelIconWrapper,
|
||||||
AddCardButton,
|
AddCardButton,
|
||||||
ComposerControls,
|
ComposerControls,
|
||||||
ComposerControlsSaveSection,
|
ComposerControlsSaveSection,
|
||||||
@ -52,7 +52,9 @@ const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
|
|||||||
>
|
>
|
||||||
Add Card
|
Add Card
|
||||||
</AddCardButton>
|
</AddCardButton>
|
||||||
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
|
<CancelIconWrapper onClick={() => onClose()}>
|
||||||
|
<Cross width={12} height={12} />
|
||||||
|
</CancelIconWrapper>
|
||||||
</ComposerControlsSaveSection>
|
</ComposerControlsSaveSection>
|
||||||
<ComposerControlsActionsSection />
|
<ComposerControlsActionsSection />
|
||||||
</ComposerControls>
|
</ComposerControls>
|
||||||
|
@ -585,3 +585,30 @@ export const ActivityItemLog = styled.span`
|
|||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
color: rgba(${props => props.theme.colors.text.primary});
|
color: rgba(${props => props.theme.colors.text.primary});
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const ViewRawButton = styled.button`
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: -24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(${props => props.theme.colors.text.primary}, 0.25);
|
||||||
|
&:hover {
|
||||||
|
color: rgba(${props => props.theme.colors.text.primary});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const TaskDetailsEditor = styled(TextareaAutosize)`
|
||||||
|
min-height: 108px;
|
||||||
|
color: #c2c6dc;
|
||||||
|
background: #262c49;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-left: 32px;
|
||||||
|
margin-right: 32px;
|
||||||
|
padding: 9px 8px 7px 8px;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
`;
|
||||||
|
@ -30,6 +30,7 @@ import {
|
|||||||
AssignUserLabel,
|
AssignUserLabel,
|
||||||
AssignUsersButton,
|
AssignUsersButton,
|
||||||
AssignedUsersSection,
|
AssignedUsersSection,
|
||||||
|
ViewRawButton,
|
||||||
DueDateTitle,
|
DueDateTitle,
|
||||||
Container,
|
Container,
|
||||||
LeftSidebar,
|
LeftSidebar,
|
||||||
@ -65,6 +66,7 @@ import {
|
|||||||
CommentProfile,
|
CommentProfile,
|
||||||
CommentInnerWrapper,
|
CommentInnerWrapper,
|
||||||
ActivitySection,
|
ActivitySection,
|
||||||
|
TaskDetailsEditor,
|
||||||
} from './Styles';
|
} from './Styles';
|
||||||
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
|
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
|
||||||
import onDragEnd from './onDragEnd';
|
import onDragEnd from './onDragEnd';
|
||||||
@ -153,6 +155,7 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const [saveTimeout, setSaveTimeout] = useState<any>(null);
|
const [saveTimeout, setSaveTimeout] = useState<any>(null);
|
||||||
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
const [showCommentActions, setShowCommentActions] = useState(false);
|
const [showCommentActions, setShowCommentActions] = useState(false);
|
||||||
const taskDescriptionRef = useRef(task.description ?? '');
|
const taskDescriptionRef = useRef(task.description ?? '');
|
||||||
const $noMemberBtn = useRef<HTMLDivElement>(null);
|
const $noMemberBtn = useRef<HTMLDivElement>(null);
|
||||||
@ -309,6 +312,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
</HeaderContainer>
|
</HeaderContainer>
|
||||||
<InnerContentContainer>
|
<InnerContentContainer>
|
||||||
<DescriptionContainer>
|
<DescriptionContainer>
|
||||||
|
{showRaw ? (
|
||||||
|
<TaskDetailsEditor value={taskDescriptionRef.current} />
|
||||||
|
) : (
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (!editTaskDescription) {
|
if (!editTaskDescription) {
|
||||||
@ -331,6 +337,9 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ViewRawButton onClick={() => setShowRaw(!showRaw)}>{showRaw ? 'Show editor' : 'Show raw'}</ViewRawButton>
|
||||||
</DescriptionContainer>
|
</DescriptionContainer>
|
||||||
<ChecklistSection>
|
<ChecklistSection>
|
||||||
<DragDropContext onDragEnd={result => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>
|
<DragDropContext onDragEnd={result => onDragEnd(result, task, onChecklistDrop, onChecklistItemDrop)}>
|
||||||
|
12
frontend/src/shared/icons/Eye.tsx
Normal file
12
frontend/src/shared/icons/Eye.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Icon, { IconProps } from './Icon';
|
||||||
|
|
||||||
|
const Eye: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
||||||
|
return (
|
||||||
|
<Icon width={width} height={height} className={className} viewBox="0 0 576 512">
|
||||||
|
<path d="M288 144a110.94 110.94 0 0 0-31.24 5 55.4 55.4 0 0 1 7.24 27 56 56 0 0 1-56 56 55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144zm284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Eye;
|
12
frontend/src/shared/icons/List.tsx
Normal file
12
frontend/src/shared/icons/List.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Icon, { IconProps } from './Icon';
|
||||||
|
|
||||||
|
const List: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
|
||||||
|
return (
|
||||||
|
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
|
||||||
|
<path d="M80 368H16a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-64a16 16 0 0 0-16-16zm0-320H16A16 16 0 0 0 0 64v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16zm0 160H16a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h64a16 16 0 0 0 16-16v-64a16 16 0 0 0-16-16zm416 176H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-320H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default List;
|
@ -1,5 +1,7 @@
|
|||||||
import Cross from './Cross';
|
import Cross from './Cross';
|
||||||
import Cog from './Cog';
|
import Cog from './Cog';
|
||||||
|
import Eye from './Eye';
|
||||||
|
import List from './List';
|
||||||
import At from './At';
|
import At from './At';
|
||||||
import Task from './Task';
|
import Task from './Task';
|
||||||
import Smile from './Smile';
|
import Smile from './Smile';
|
||||||
@ -85,4 +87,6 @@ export {
|
|||||||
Clone,
|
Clone,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Share,
|
Share,
|
||||||
|
Eye,
|
||||||
|
List,
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
let accessToken = '';
|
let accessToken = '';
|
||||||
|
|
||||||
export function setAccessToken(newToken: string) {
|
export function setAccessToken(newToken: string) {
|
||||||
|
console.log(newToken);
|
||||||
accessToken = newToken;
|
accessToken = newToken;
|
||||||
}
|
}
|
||||||
export function getAccessToken() {
|
export function getAccessToken() {
|
||||||
|
@ -7,8 +7,6 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
var jwtKey = []byte("taskcafe_test_key")
|
|
||||||
|
|
||||||
// RestrictedMode is used restrict JWT access to just the install route
|
// RestrictedMode is used restrict JWT access to just the install route
|
||||||
type RestrictedMode string
|
type RestrictedMode string
|
||||||
|
|
||||||
@ -54,7 +52,7 @@ func (r *ErrMalformedToken) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAccessToken generates a new JWT access token with the correct claims
|
// NewAccessToken generates a new JWT access token with the correct claims
|
||||||
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string) (string, error) {
|
func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string, jwtKey []byte) (string, error) {
|
||||||
role := RoleMember
|
role := RoleMember
|
||||||
if orgRole == "admin" {
|
if orgRole == "admin" {
|
||||||
role = RoleAdmin
|
role = RoleAdmin
|
||||||
@ -76,7 +74,7 @@ func NewAccessToken(userID string, restrictedMode RestrictedMode, orgRole string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAccessTokenCustomExpiration creates an access token with a custom duration
|
// NewAccessTokenCustomExpiration creates an access token with a custom duration
|
||||||
func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, error) {
|
func NewAccessTokenCustomExpiration(userID string, dur time.Duration, jwtKey []byte) (string, error) {
|
||||||
accessExpirationTime := time.Now().Add(dur)
|
accessExpirationTime := time.Now().Add(dur)
|
||||||
accessClaims := &AccessTokenClaims{
|
accessClaims := &AccessTokenClaims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@ -94,7 +92,7 @@ func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ValidateAccessToken validates a JWT access token and returns the contained claims or an error if it's invalid
|
// ValidateAccessToken validates a JWT access token and returns the contained claims or an error if it's invalid
|
||||||
func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) {
|
func ValidateAccessToken(accessTokenString string, jwtKey []byte) (AccessTokenClaims, error) {
|
||||||
accessClaims := &AccessTokenClaims{}
|
accessClaims := &AccessTokenClaims{}
|
||||||
accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) {
|
accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) {
|
||||||
return jwtKey, nil
|
return jwtKey, nil
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jordanknott/taskcafe/internal/auth"
|
"github.com/jordanknott/taskcafe/internal/auth"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTokenCmd() *cobra.Command {
|
func newTokenCmd() *cobra.Command {
|
||||||
@ -15,13 +18,18 @@ func newTokenCmd() *cobra.Command {
|
|||||||
Short: "Create a long lived JWT token for dev purposes",
|
Short: "Create a long lived JWT token for dev purposes",
|
||||||
Long: "Create a long lived JWT token for dev purposes",
|
Long: "Create a long lived JWT token for dev purposes",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
token, err := auth.NewAccessTokenCustomExpiration(args[0], time.Hour*24)
|
secret := viper.GetString("server.secret")
|
||||||
|
if strings.TrimSpace(secret) == "" {
|
||||||
|
return errors.New("server.secret must be set (TASKCAFE_SERVER_SECRET)")
|
||||||
|
}
|
||||||
|
token, err := auth.NewAccessTokenCustomExpiration(args[0], time.Hour*24, []byte(secret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("issue while creating access token")
|
log.WithError(err).Error("issue while creating access token")
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println(token)
|
fmt.Println(token)
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,11 +3,13 @@ package commands
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
"github.com/golang-migrate/migrate/v4/source/httpfs"
|
"github.com/golang-migrate/migrate/v4/source/httpfs"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
@ -38,17 +40,22 @@ func newWebCmd() *cobra.Command {
|
|||||||
)
|
)
|
||||||
var db *sqlx.DB
|
var db *sqlx.DB
|
||||||
var err error
|
var err error
|
||||||
retryNumber := 0
|
var retryDuration time.Duration
|
||||||
for i := 0; retryNumber <= 3; i++ {
|
maxRetryNumber := 4
|
||||||
retryNumber++
|
for i := 0; i < maxRetryNumber; i++ {
|
||||||
db, err = sqlx.Connect("postgres", connection)
|
db, err = sqlx.Connect("postgres", connection)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
retryDuration := time.Duration(i*2) * time.Second
|
retryDuration = time.Duration(i*2) * time.Second
|
||||||
log.WithFields(log.Fields{"retryNumber": retryNumber, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying")
|
log.WithFields(log.Fields{"retryNumber": i, "retryDuration": retryDuration}).WithError(err).Error("issue connecting to database, retrying")
|
||||||
|
if i != maxRetryNumber-1 {
|
||||||
time.Sleep(retryDuration)
|
time.Sleep(retryDuration)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
db.SetMaxOpenConns(25)
|
db.SetMaxOpenConns(25)
|
||||||
db.SetMaxIdleConns(25)
|
db.SetMaxIdleConns(25)
|
||||||
db.SetConnMaxLifetime(5 * time.Minute)
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
@ -62,7 +69,12 @@ func newWebCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
|
log.WithFields(log.Fields{"url": viper.GetString("server.hostname")}).Info("starting server")
|
||||||
r, _ := route.NewRouter(db)
|
secret := viper.GetString("server.secret")
|
||||||
|
if strings.TrimSpace(secret) == "" {
|
||||||
|
log.Warn("server.secret is not set, generating a random secret")
|
||||||
|
secret = uuid.New().String()
|
||||||
|
}
|
||||||
|
r, _ := route.NewRouter(db, []byte(secret))
|
||||||
http.ListenAndServe(viper.GetString("server.hostname"), r)
|
http.ListenAndServe(viper.GetString("server.hostname"), r)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
@ -14,8 +14,6 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var jwtKey = []byte("taskcafe_test_key")
|
|
||||||
|
|
||||||
type authResource struct{}
|
type authResource struct{}
|
||||||
|
|
||||||
// LoginRequestData is the request data when a user logs in
|
// LoginRequestData is the request data when a user logs in
|
||||||
@ -69,7 +67,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
|
|||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode)
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode, h.jwtKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@ -123,7 +121,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
|
|||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode)
|
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@ -190,7 +188,7 @@ func (h *TaskcafeHandler) 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(), auth.Unrestricted, user.RoleCode)
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@ -251,10 +249,12 @@ func (h *TaskcafeHandler) InstallHandler(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(), auth.Unrestricted, user.RoleCode)
|
log.WithField("userID", user.UserID.String()).Info("creating install access token")
|
||||||
|
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
log.Info(accessTokenString)
|
||||||
|
|
||||||
w.Header().Set("Content-type", "application/json")
|
w.Header().Set("Content-type", "application/json")
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
@ -5,7 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@ -48,22 +50,24 @@ func (h *TaskcafeHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
log.WithFields(log.Fields{"filename": handler.Filename, "size": handler.Size, "header": handler.Header}).Info("file metadata")
|
filename := strings.ReplaceAll(handler.Filename, " ", "-")
|
||||||
|
encodedFilename := url.QueryEscape(filename)
|
||||||
|
log.WithFields(log.Fields{"filename": encodedFilename, "size": handler.Size, "header": handler.Header}).Info("file metadata")
|
||||||
|
|
||||||
fileBytes, err := ioutil.ReadAll(file)
|
fileBytes, err := ioutil.ReadAll(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("while reading file")
|
log.WithError(err).Error("while reading file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = ioutil.WriteFile("uploads/"+handler.Filename, fileBytes, 0644)
|
err = ioutil.WriteFile("uploads/"+filename, fileBytes, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Error("while reading file")
|
log.WithError(err).Error("while reading file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.repo.UpdateUserAccountProfileAvatarURL(r.Context(), db.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{String: "http://localhost:3333/uploads/" + handler.Filename, Valid: true}})
|
h.repo.UpdateUserAccountProfileAvatarURL(r.Context(), db.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{String: "/uploads/" + encodedFilename, Valid: true}})
|
||||||
// return that we have successfully uploaded our file!
|
// return that we have successfully uploaded our file!
|
||||||
log.Info("file uploaded")
|
log.Info("file uploaded")
|
||||||
json.NewEncoder(w).Encode(AvatarUploadResponseData{URL: "http://localhost:3333/uploads/" + handler.Filename, UserID: userID.String()})
|
json.NewEncoder(w).Encode(AvatarUploadResponseData{URL: "/uploads/" + encodedFilename, UserID: userID.String()})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header
|
// AuthenticationMiddleware is a middleware that requires a valid JWT token to be passed via the Authorization header
|
||||||
func AuthenticationMiddleware(next http.Handler) http.Handler {
|
type AuthenticationMiddleware struct {
|
||||||
|
jwtKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware returns the middleware handler
|
||||||
|
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
bearerTokenRaw := r.Header.Get("Authorization")
|
bearerTokenRaw := r.Header.Get("Authorization")
|
||||||
splitToken := strings.Split(bearerTokenRaw, "Bearer")
|
splitToken := strings.Split(bearerTokenRaw, "Bearer")
|
||||||
@ -21,7 +26,7 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
accessTokenString := strings.TrimSpace(splitToken[1])
|
accessTokenString := strings.TrimSpace(splitToken[1])
|
||||||
accessClaims, err := auth.ValidateAccessToken(accessTokenString)
|
accessClaims, err := auth.ValidateAccessToken(accessTokenString, m.jwtKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := err.(*auth.ErrExpiredToken); ok {
|
if _, ok := err.(*auth.ErrExpiredToken); ok {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
@ -60,10 +60,11 @@ func (h FrontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// TaskcafeHandler contains all the route handlers
|
// TaskcafeHandler contains all the route handlers
|
||||||
type TaskcafeHandler struct {
|
type TaskcafeHandler struct {
|
||||||
repo db.Repository
|
repo db.Repository
|
||||||
|
jwtKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter creates a new router for chi
|
// NewRouter creates a new router for chi
|
||||||
func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
|
func NewRouter(dbConnection *sqlx.DB, jwtKey []byte) (chi.Router, error) {
|
||||||
formatter := new(log.TextFormatter)
|
formatter := new(log.TextFormatter)
|
||||||
formatter.TimestampFormat = "02-01-2006 15:04:05"
|
formatter.TimestampFormat = "02-01-2006 15:04:05"
|
||||||
formatter.FullTimestamp = true
|
formatter.FullTimestamp = true
|
||||||
@ -79,7 +80,7 @@ func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
|
|||||||
r.Use(middleware.Timeout(60 * time.Second))
|
r.Use(middleware.Timeout(60 * time.Second))
|
||||||
|
|
||||||
repository := db.NewRepository(dbConnection)
|
repository := db.NewRepository(dbConnection)
|
||||||
taskcafeHandler := TaskcafeHandler{*repository}
|
taskcafeHandler := TaskcafeHandler{*repository, jwtKey}
|
||||||
|
|
||||||
var imgServer = http.FileServer(http.Dir("./uploads/"))
|
var imgServer = http.FileServer(http.Dir("./uploads/"))
|
||||||
r.Group(func(mux chi.Router) {
|
r.Group(func(mux chi.Router) {
|
||||||
@ -88,8 +89,9 @@ func NewRouter(dbConnection *sqlx.DB) (chi.Router, error) {
|
|||||||
mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer))
|
mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer))
|
||||||
|
|
||||||
})
|
})
|
||||||
|
auth := AuthenticationMiddleware{jwtKey}
|
||||||
r.Group(func(mux chi.Router) {
|
r.Group(func(mux chi.Router) {
|
||||||
mux.Use(AuthenticationMiddleware)
|
mux.Use(auth.Middleware)
|
||||||
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
|
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
|
||||||
mux.Post("/auth/install", taskcafeHandler.InstallHandler)
|
mux.Post("/auth/install", taskcafeHandler.InstallHandler)
|
||||||
mux.Handle("/graphql", graph.NewHandler(*repository))
|
mux.Handle("/graphql", graph.NewHandler(*repository))
|
||||||
|
Reference in New Issue
Block a user