initial commit

This commit is contained in:
Jordan Knott 2020-04-09 21:40:22 -05:00
commit 9611105364
141 changed files with 29236 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

13
api/Pipfile Normal file
View File

@ -0,0 +1,13 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
click = "*"
requests = "*"
[requires]
python_version = "3.8"

18
api/README.md Normal file
View File

@ -0,0 +1,18 @@
## Authentication
Uses a refresh_token and access_token system.
The refresh_token is an opaque UUID based token. The access_token is a JWT
token containing several claims such as `sub` & `roles`
The refresh_token is stored in a database and is long lived (24 hours). It is sent to the client
as a cookie set to be `HttpOnly`.
The access_token is not stored in the database & is only stored in memory on the client side.
It is short lived (5 minutes).
The access_token is used to authenticate all endpoints except endpoints under /auth
The access_token is refreshed using the refresh_token through the /auth/refresh_token endpoint.
This endpoint takes in the refresh_token set VIA a cookie header & returns a new refresh_token & access_token
if the refresh_token is still valid. The old refresh_token is invalidated.

28
api/cmd/citadel/main.go Normal file
View File

@ -0,0 +1,28 @@
package main
import (
_ "github.com/lib/pq"
"fmt"
"net/http"
"github.com/jmoiron/sqlx"
"github.com/jordanknott/project-citadel/api/router"
log "github.com/sirupsen/logrus"
)
func main() {
Formatter := new(log.TextFormatter)
Formatter.TimestampFormat = "02-01-2006 15:04:05"
Formatter.FullTimestamp = true
log.SetFormatter(Formatter)
db, err := sqlx.Connect("postgres", "user=postgres password=test host=0.0.0.0 dbname=citadel sslmode=disable")
if err != nil {
log.Panic(err)
}
defer db.Close()
fmt.Println("starting graphql server on http://localhost:3333")
fmt.Println("starting graphql playground on http://localhost:3333/__graphql")
r, _ := router.NewRouter(db)
http.ListenAndServe(":3333", r)
}

View File

@ -0,0 +1,16 @@
package main
import (
"fmt"
"github.com/jordanknott/project-citadel/api/router"
"time"
)
func main() {
dur := time.Hour * 24 * 7 * 30
token, err := router.NewAccessTokenCustomExpiration("21345076-6423-4a00-a6bd-cd9f830e2764", dur)
if err != nil {
panic(err)
}
fmt.Println(token)
}

18
api/go.mod Normal file
View File

@ -0,0 +1,18 @@
module github.com/jordanknott/project-citadel/api
go 1.13
require (
github.com/99designs/gqlgen v0.11.1
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/go-chi/chi v3.3.2+incompatible
github.com/go-chi/cors v1.0.0
github.com/google/martian v2.1.0+incompatible
github.com/google/uuid v1.1.1
github.com/jmoiron/sqlx v1.2.0
github.com/lib/pq v1.0.0
github.com/pkg/errors v0.8.1
github.com/sirupsen/logrus v1.4.2
github.com/vektah/gqlparser/v2 v2.0.1
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
)

105
api/go.sum Normal file
View File

@ -0,0 +1,105 @@
github.com/99designs/gqlgen v0.11.1 h1:QoSL8/AAJ2T3UOeQbdnBR32JcG4pO08+P/g5jdbFkUg=
github.com/99designs/gqlgen v0.11.1/go.mod h1:vjFOyBZ7NwDl+GdSD4PFn7BQn5Fy7ohJwXn7Vk8zz+c=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0=
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/go-chi/chi v3.3.2+incompatible h1:uQNcQN3NsV1j4ANsPh42P4ew4t6rnRbJb8frvpp31qQ=
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0=
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8=
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM=
golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=

47
api/gqlgen.yml Normal file
View File

@ -0,0 +1,47 @@
# Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema:
- graph/*.graphqls
# Where should the generated server code go?
exec:
filename: graph/generated.go
package: graph
# Uncomment to enable federation
# federation:
# filename: graph/generated/federation.go
# package: generated
# Where should any generated models go?
model:
filename: graph/models_gen.go
package: graph
# Where should the resolver implementations go?
resolver:
layout: follow-schema
dir: graph
package: graph
# Optional: turn on to use []Thing instead of []*Thing
omit_slice_element_pointers: true
# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
- "github.com/jordanknott/project-citadel/api/pg"
# This section declares type mapping between the GraphQL and go type systems
#
# The first line in each type will be used as defaults for resolver arguments and
# modelgen, the others will be allowed when binding to fields. Configure them to
# your liking
models:
ID:
model: github.com/jordanknott/project-citadel/api/graph.UUID
Int:
model:
- github.com/99designs/gqlgen/graphql.Int
UUID:
model: github.com/jordanknott/project-citadel/api/graph.UUID

6217
api/graph/generated.go Normal file

File diff suppressed because it is too large Load Diff

22
api/graph/graph.go Normal file
View File

@ -0,0 +1,22 @@
package graph
import (
"net/http"
"github.com/99designs/gqlgen/handler"
"github.com/jordanknott/project-citadel/api/pg"
)
// NewHandler returns a new graphql endpoint handler.
func NewHandler(repo pg.Repository) http.Handler {
return handler.GraphQL(NewExecutableSchema(Config{
Resolvers: &Resolver{
Repository: repo,
},
}))
}
// NewPlaygroundHandler returns a new GraphQL Playground handler.
func NewPlaygroundHandler(endpoint string) http.Handler {
return handler.Playground("GraphQL Playground", endpoint)
}

75
api/graph/models_gen.go Normal file
View File

@ -0,0 +1,75 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package graph
type DeleteTaskInput struct {
TaskID string `json:"taskID"`
}
type DeleteTaskPayload struct {
TaskID string `json:"taskID"`
}
type FindProject struct {
ProjectID string `json:"projectId"`
}
type FindUser struct {
UserID string `json:"userId"`
}
type LogoutUser struct {
UserID string `json:"userID"`
}
type NewOrganization struct {
Name string `json:"name"`
}
type NewProject struct {
TeamID string `json:"teamID"`
Name string `json:"name"`
}
type NewRefreshToken struct {
UserID string `json:"userId"`
}
type NewTask struct {
TaskGroupID string `json:"taskGroupID"`
Name string `json:"name"`
Position float64 `json:"position"`
}
type NewTaskGroup struct {
ProjectID string `json:"projectID"`
Name string `json:"name"`
Position float64 `json:"position"`
}
type NewTaskLocation struct {
TaskID string `json:"taskID"`
TaskGroupID string `json:"taskGroupID"`
Position float64 `json:"position"`
}
type NewTeam struct {
Name string `json:"name"`
OrganizationID string `json:"organizationID"`
}
type NewUserAccount struct {
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
}
type ProjectsFilter struct {
TeamID *string `json:"teamID"`
}
type UpdateTaskName struct {
TaskID string `json:"taskID"`
Name string `json:"name"`
}

13
api/graph/resolver.go Normal file
View File

@ -0,0 +1,13 @@
//go:generate go run github.com/99designs/gqlgen
package graph
import (
"sync"
"github.com/jordanknott/project-citadel/api/pg"
)
type Resolver struct {
Repository pg.Repository
mu sync.Mutex
}

23
api/graph/scalars.go Normal file
View File

@ -0,0 +1,23 @@
package graph
import (
"io"
"github.com/99designs/gqlgen/graphql"
"github.com/google/uuid"
"github.com/pkg/errors"
"strconv"
)
func MarshalUUID(uuid uuid.UUID) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
w.Write([]byte(strconv.Quote(uuid.String())))
})
}
func UnmarshalUUID(v interface{}) (uuid.UUID, error) {
if uuidRaw, ok := v.(string); ok {
return uuid.Parse(uuidRaw)
}
return uuid.UUID{}, errors.New("uuid must be a string")
}

151
api/graph/schema.graphqls Normal file
View File

@ -0,0 +1,151 @@
scalar Time
scalar UUID
type RefreshToken {
tokenId: ID!
userId: UUID!
expiresAt: Time!
createdAt: Time!
}
type UserAccount {
userID: ID!
email: String!
createdAt: Time!
displayName: String!
username: String!
}
type Organization {
organizationID: ID!
createdAt: Time!
name: String!
teams: [Team!]!
}
type Team {
teamID: ID!
createdAt: Time!
name: String!
projects: [Project!]!
}
type Project {
projectID: ID!
teamID: String!
createdAt: Time!
name: String!
taskGroups: [TaskGroup!]!
}
type TaskGroup {
taskGroupID: ID!
projectID: String!
createdAt: Time!
name: String!
position: Float!
tasks: [Task!]!
}
type Task {
taskID: ID!
taskGroupID: String!
createdAt: Time!
name: String!
position: Float!
}
input ProjectsFilter {
teamID: String
}
input FindUser {
userId: String!
}
input FindProject {
projectId: String!
}
type Query {
organizations: [Organization!]!
users: [UserAccount!]!
findUser(input: FindUser!): UserAccount!
findProject(input: FindProject!): Project!
teams: [Team!]!
projects(input: ProjectsFilter): [Project!]!
taskGroups: [TaskGroup!]!
}
input NewRefreshToken {
userId: String!
}
input NewUserAccount {
username: String!
email: String!
displayName: String!
password: String!
}
input NewTeam {
name: String!
organizationID: String!
}
input NewProject {
teamID: String!
name: String!
}
input NewTaskGroup {
projectID: String!
name: String!
position: Float!
}
input NewOrganization {
name: String!
}
input LogoutUser {
userID: String!
}
input NewTask {
taskGroupID: String!
name: String!
position: Float!
}
input NewTaskLocation {
taskID: String!
taskGroupID: String!
position: Float!
}
input DeleteTaskInput {
taskID: String!
}
type DeleteTaskPayload {
taskID: String!
}
input UpdateTaskName {
taskID: String!
name: String!
}
type Mutation {
createRefreshToken(input: NewRefreshToken!): RefreshToken!
createUserAccount(input: NewUserAccount!): UserAccount!
createOrganization(input: NewOrganization!): Organization!
createTeam(input: NewTeam!): Team!
createProject(input: NewProject!): Project!
createTaskGroup(input: NewTaskGroup!): TaskGroup!
createTask(input: NewTask!): Task!
updateTaskLocation(input: NewTaskLocation!): Task!
logoutUser(input: LogoutUser!): Boolean!
updateTaskName(input: UpdateTaskName!): Task!
deleteTask(input: DeleteTaskInput!): DeleteTaskPayload!
}

View File

@ -0,0 +1,232 @@
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
package graph
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
"github.com/jordanknott/project-citadel/api/pg"
log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror"
)
func (r *mutationResolver) CreateRefreshToken(ctx context.Context, input NewRefreshToken) (*pg.RefreshToken, error) {
userID := uuid.MustParse("0183d9ab-d0ed-4c9b-a3df-77a0cdd93dca")
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshToken, err := r.Repository.CreateRefreshToken(ctx, pg.CreateRefreshTokenParams{userID, refreshCreatedAt, refreshExpiresAt})
return &refreshToken, err
}
func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*pg.UserAccount, error) {
createdAt := time.Now().UTC()
userAccount, err := r.Repository.CreateUserAccount(ctx, pg.CreateUserAccountParams{input.Username, input.Email, input.DisplayName, createdAt, input.Password})
return &userAccount, err
}
func (r *mutationResolver) CreateOrganization(ctx context.Context, input NewOrganization) (*pg.Organization, error) {
createdAt := time.Now().UTC()
organization, err := r.Repository.CreateOrganization(ctx, pg.CreateOrganizationParams{createdAt, input.Name})
return &organization, err
}
func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*pg.Team, error) {
organizationID, err := uuid.Parse(input.OrganizationID)
if err != nil {
return &pg.Team{}, err
}
createdAt := time.Now().UTC()
team, err := r.Repository.CreateTeam(ctx, pg.CreateTeamParams{organizationID, createdAt, input.Name})
return &team, err
}
func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject) (*pg.Project, error) {
createdAt := time.Now().UTC()
teamID, err := uuid.Parse(input.TeamID)
if err != nil {
return &pg.Project{}, err
}
project, err := r.Repository.CreateProject(ctx, pg.CreateProjectParams{teamID, createdAt, input.Name})
return &project, err
}
func (r *mutationResolver) CreateTaskGroup(ctx context.Context, input NewTaskGroup) (*pg.TaskGroup, error) {
createdAt := time.Now().UTC()
projectID, err := uuid.Parse(input.ProjectID)
if err != nil {
return &pg.TaskGroup{}, err
}
project, err := r.Repository.CreateTaskGroup(ctx,
pg.CreateTaskGroupParams{projectID, createdAt, input.Name, input.Position})
return &project, err
}
func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*pg.Task, error) {
taskGroupID, err := uuid.Parse(input.TaskGroupID)
createdAt := time.Now().UTC()
if err != nil {
return &pg.Task{}, err
}
task, err := r.Repository.CreateTask(ctx, pg.CreateTaskParams{taskGroupID, createdAt, input.Name, input.Position})
return &task, err
}
func (r *mutationResolver) UpdateTaskLocation(ctx context.Context, input NewTaskLocation) (*pg.Task, error) {
taskID, err := uuid.Parse(input.TaskID)
if err != nil {
return &pg.Task{}, err
}
taskGroupID, err := uuid.Parse(input.TaskGroupID)
if err != nil {
return &pg.Task{}, err
}
task, err := r.Repository.UpdateTaskLocation(ctx, pg.UpdateTaskLocationParams{taskID, taskGroupID, input.Position})
return &task, err
}
func (r *mutationResolver) LogoutUser(ctx context.Context, input LogoutUser) (bool, error) {
userID, err := uuid.Parse(input.UserID)
if err != nil {
return false, err
}
err = r.Repository.DeleteRefreshTokenByUserID(ctx, userID)
return true, err
}
func (r *mutationResolver) UpdateTaskName(ctx context.Context, input UpdateTaskName) (*pg.Task, error) {
taskID, err := uuid.Parse(input.TaskID)
if err != nil {
return &pg.Task{}, err
}
task, err := r.Repository.UpdateTaskName(ctx, pg.UpdateTaskNameParams{taskID, input.Name})
return &task, err
}
func (r *mutationResolver) DeleteTask(ctx context.Context, input DeleteTaskInput) (*DeleteTaskPayload, error) {
taskID, err := uuid.Parse(input.TaskID)
if err != nil {
return &DeleteTaskPayload{}, err
}
log.WithFields(log.Fields{
"taskID": taskID.String(),
}).Info("deleting task")
err = r.Repository.DeleteTaskByID(ctx, taskID)
if err != nil {
return &DeleteTaskPayload{}, err
}
return &DeleteTaskPayload{taskID.String()}, nil
}
func (r *organizationResolver) Teams(ctx context.Context, obj *pg.Organization) ([]pg.Team, error) {
teams, err := r.Repository.GetTeamsForOrganization(ctx, obj.OrganizationID)
return teams, err
}
func (r *projectResolver) TeamID(ctx context.Context, obj *pg.Project) (string, error) {
return obj.TeamID.String(), nil
}
func (r *projectResolver) TaskGroups(ctx context.Context, obj *pg.Project) ([]pg.TaskGroup, error) {
return r.Repository.GetTaskGroupsForProject(ctx, obj.ProjectID)
}
func (r *queryResolver) Organizations(ctx context.Context) ([]pg.Organization, error) {
return r.Repository.GetAllOrganizations(ctx)
}
func (r *queryResolver) Users(ctx context.Context) ([]pg.UserAccount, error) {
return r.Repository.GetAllUserAccounts(ctx)
}
func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*pg.UserAccount, error) {
userId, err := uuid.Parse(input.UserID)
if err != nil {
return &pg.UserAccount{}, err
}
account, err := r.Repository.GetUserAccountByID(ctx, userId)
if err == sql.ErrNoRows {
return &pg.UserAccount{}, &gqlerror.Error{
Message: "User not found",
Extensions: map[string]interface{}{
"code": "10-404",
},
}
}
return &account, err
}
func (r *queryResolver) FindProject(ctx context.Context, input FindProject) (*pg.Project, error) {
projectID, err := uuid.Parse(input.ProjectID)
if err != nil {
return &pg.Project{}, err
}
project, err := r.Repository.GetProjectByID(ctx, projectID)
if err == sql.ErrNoRows {
return &pg.Project{}, &gqlerror.Error{
Message: "Project not found",
Extensions: map[string]interface{}{
"code": "11-404",
},
}
}
return &project, err
}
func (r *queryResolver) Teams(ctx context.Context) ([]pg.Team, error) {
return r.Repository.GetAllTeams(ctx)
}
func (r *queryResolver) Projects(ctx context.Context, input *ProjectsFilter) ([]pg.Project, error) {
if input != nil {
teamID, err := uuid.Parse(*input.TeamID)
if err != nil {
return []pg.Project{}, err
}
return r.Repository.GetAllProjectsForTeam(ctx, teamID)
}
return r.Repository.GetAllProjects(ctx)
}
func (r *queryResolver) TaskGroups(ctx context.Context) ([]pg.TaskGroup, error) {
return r.Repository.GetAllTaskGroups(ctx)
}
func (r *taskResolver) TaskGroupID(ctx context.Context, obj *pg.Task) (string, error) {
return obj.TaskGroupID.String(), nil
}
func (r *taskGroupResolver) ProjectID(ctx context.Context, obj *pg.TaskGroup) (string, error) {
return obj.ProjectID.String(), nil
}
func (r *taskGroupResolver) Tasks(ctx context.Context, obj *pg.TaskGroup) ([]pg.Task, error) {
tasks, err := r.Repository.GetTasksForTaskGroupID(ctx, obj.TaskGroupID)
return tasks, err
}
func (r *teamResolver) Projects(ctx context.Context, obj *pg.Team) ([]pg.Project, error) {
return r.Repository.GetAllProjectsForTeam(ctx, obj.TeamID)
}
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
func (r *Resolver) Organization() OrganizationResolver { return &organizationResolver{r} }
func (r *Resolver) Project() ProjectResolver { return &projectResolver{r} }
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
func (r *Resolver) Task() TaskResolver { return &taskResolver{r} }
func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} }
func (r *Resolver) Team() TeamResolver { return &teamResolver{r} }
type mutationResolver struct{ *Resolver }
type organizationResolver struct{ *Resolver }
type projectResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type taskResolver struct{ *Resolver }
type taskGroupResolver struct{ *Resolver }
type teamResolver struct{ *Resolver }

View File

@ -0,0 +1,6 @@
CREATE TABLE refresh_token (
token_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL
);

View File

@ -0,0 +1,8 @@
CREATE TABLE user_account (
user_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL,
display_name text NOT NULL,
email text NOT NULL UNIQUE,
username text NOT NULL UNIQUE,
password_hash text NOT NULL
);

View File

@ -0,0 +1,5 @@
CREATE TABLE team (
team_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL,
name text NOT NULL
);

View File

@ -0,0 +1,6 @@
CREATE TABLE project (
project_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
team_id uuid NOT NULL REFERENCES team(team_id),
created_at timestamptz NOT NULL,
name text NOT NULL
);

View File

@ -0,0 +1,7 @@
CREATE TABLE task_group (
task_group_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id uuid NOT NULL REFERENCES project(project_id),
created_at timestamptz NOT NULL,
name text NOT NULL,
position float NOT NULL
);

View File

@ -0,0 +1,7 @@
CREATE TABLE task (
task_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
task_group_id uuid NOT NULL REFERENCES task_group(task_group_id),
created_at timestamptz NOT NULL,
name text NOT NULL,
position float NOT NULL
);

View File

@ -0,0 +1,5 @@
CREATE TABLE organization (
organization_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at timestamptz NOT NULL,
name text NOT NULL
);

View File

@ -0,0 +1 @@
ALTER TABLE team ADD COLUMN organization_id uuid NOT NULL REFERENCES organization(organization_id);

29
api/pg/db.go Normal file
View File

@ -0,0 +1,29 @@
// Code generated by sqlc. DO NOT EDIT.
package pg
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

61
api/pg/models.go Normal file
View File

@ -0,0 +1,61 @@
// Code generated by sqlc. DO NOT EDIT.
package pg
import (
"time"
"github.com/google/uuid"
)
type Organization struct {
OrganizationID uuid.UUID `json:"organization_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
}
type Project struct {
ProjectID uuid.UUID `json:"project_id"`
TeamID uuid.UUID `json:"team_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
}
type RefreshToken struct {
TokenID uuid.UUID `json:"token_id"`
UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type Task struct {
TaskID uuid.UUID `json:"task_id"`
TaskGroupID uuid.UUID `json:"task_group_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Position float64 `json:"position"`
}
type TaskGroup struct {
TaskGroupID uuid.UUID `json:"task_group_id"`
ProjectID uuid.UUID `json:"project_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Position float64 `json:"position"`
}
type Team struct {
TeamID uuid.UUID `json:"team_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
OrganizationID uuid.UUID `json:"organization_id"`
}
type UserAccount struct {
UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
}

View File

@ -0,0 +1,52 @@
// Code generated by sqlc. DO NOT EDIT.
// source: organization.sql
package pg
import (
"context"
"time"
)
const createOrganization = `-- name: CreateOrganization :one
INSERT INTO organization (created_at, name) VALUES ($1, $2) RETURNING organization_id, created_at, name
`
type CreateOrganizationParams struct {
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
}
func (q *Queries) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error) {
row := q.db.QueryRowContext(ctx, createOrganization, arg.CreatedAt, arg.Name)
var i Organization
err := row.Scan(&i.OrganizationID, &i.CreatedAt, &i.Name)
return i, err
}
const getAllOrganizations = `-- name: GetAllOrganizations :many
SELECT organization_id, created_at, name FROM organization
`
func (q *Queries) GetAllOrganizations(ctx context.Context) ([]Organization, error) {
rows, err := q.db.QueryContext(ctx, getAllOrganizations)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Organization
for rows.Next() {
var i Organization
if err := rows.Scan(&i.OrganizationID, &i.CreatedAt, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

51
api/pg/pg.go Normal file
View File

@ -0,0 +1,51 @@
package pg
import (
"context"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type Repository interface {
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
GetAllTeams(ctx context.Context) ([]Team, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
GetAllProjects(ctx context.Context) ([]Project, error)
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)
DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error
DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
GetAllTaskGroups(ctx context.Context) ([]TaskGroup, error)
GetAllOrganizations(ctx context.Context) ([]Organization, error)
GetTaskGroupsForProject(ctx context.Context, projectID uuid.UUID) ([]TaskGroup, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error)
GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
GetAllTasks(ctx context.Context) ([]Task, error)
GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error)
UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error)
DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error
UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error)
}
type repoSvc struct {
*Queries
db *sqlx.DB
}
// NewRepository returns an implementation of the Repository interface.
func NewRepository(db *sqlx.DB) Repository {
return &repoSvc{
Queries: New(db.DB),
db: db,
}
}

113
api/pg/project.sql.go Normal file
View File

@ -0,0 +1,113 @@
// Code generated by sqlc. DO NOT EDIT.
// source: project.sql
package pg
import (
"context"
"time"
"github.com/google/uuid"
)
const createProject = `-- name: CreateProject :one
INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING project_id, team_id, created_at, name
`
type CreateProjectParams struct {
TeamID uuid.UUID `json:"team_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
row := q.db.QueryRowContext(ctx, createProject, arg.TeamID, arg.CreatedAt, arg.Name)
var i Project
err := row.Scan(
&i.ProjectID,
&i.TeamID,
&i.CreatedAt,
&i.Name,
)
return i, err
}
const getAllProjects = `-- name: GetAllProjects :many
SELECT project_id, team_id, created_at, name FROM project
`
func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getAllProjects)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Project
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ProjectID,
&i.TeamID,
&i.CreatedAt,
&i.Name,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAllProjectsForTeam = `-- name: GetAllProjectsForTeam :many
SELECT project_id, team_id, created_at, name FROM project WHERE team_id = $1
`
func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) {
rows, err := q.db.QueryContext(ctx, getAllProjectsForTeam, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Project
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ProjectID,
&i.TeamID,
&i.CreatedAt,
&i.Name,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getProjectByID = `-- name: GetProjectByID :one
SELECT project_id, team_id, created_at, name FROM project WHERE project_id = $1
`
func (q *Queries) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error) {
row := q.db.QueryRowContext(ctx, getProjectByID, projectID)
var i Project
err := row.Scan(
&i.ProjectID,
&i.TeamID,
&i.CreatedAt,
&i.Name,
)
return i, err
}

43
api/pg/querier.go Normal file
View File

@ -0,0 +1,43 @@
// Code generated by sqlc. DO NOT EDIT.
package pg
import (
"context"
"github.com/google/uuid"
)
type Querier interface {
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (Organization, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
DeleteExpiredTokens(ctx context.Context) error
DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error
DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error
DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error
DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error
GetAllOrganizations(ctx context.Context) ([]Organization, error)
GetAllProjects(ctx context.Context) ([]Project, error)
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)
GetAllTaskGroups(ctx context.Context) ([]TaskGroup, error)
GetAllTasks(ctx context.Context) ([]Task, error)
GetAllTeams(ctx context.Context) ([]Team, error)
GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)
GetTaskGroupsForProject(ctx context.Context, projectID uuid.UUID) ([]TaskGroup, error)
GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error)
GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error)
GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error)
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)
UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error)
UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error)
}
var _ Querier = (*Queries)(nil)

161
api/pg/task.sql.go Normal file
View File

@ -0,0 +1,161 @@
// Code generated by sqlc. DO NOT EDIT.
// source: task.sql
package pg
import (
"context"
"time"
"github.com/google/uuid"
)
const createTask = `-- name: CreateTask :one
INSERT INTO task (task_group_id, created_at, name, position)
VALUES($1, $2, $3, $4) RETURNING task_id, task_group_id, created_at, name, position
`
type CreateTaskParams struct {
TaskGroupID uuid.UUID `json:"task_group_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Position float64 `json:"position"`
}
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
row := q.db.QueryRowContext(ctx, createTask,
arg.TaskGroupID,
arg.CreatedAt,
arg.Name,
arg.Position,
)
var i Task
err := row.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
)
return i, err
}
const deleteTaskByID = `-- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1
`
func (q *Queries) DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteTaskByID, taskID)
return err
}
const getAllTasks = `-- name: GetAllTasks :many
SELECT task_id, task_group_id, created_at, name, position FROM task
`
func (q *Queries) GetAllTasks(ctx context.Context) ([]Task, error) {
rows, err := q.db.QueryContext(ctx, getAllTasks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var i Task
if err := rows.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTasksForTaskGroupID = `-- name: GetTasksForTaskGroupID :many
SELECT task_id, task_group_id, created_at, name, position FROM task WHERE task_group_id = $1
`
func (q *Queries) GetTasksForTaskGroupID(ctx context.Context, taskGroupID uuid.UUID) ([]Task, error) {
rows, err := q.db.QueryContext(ctx, getTasksForTaskGroupID, taskGroupID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var i Task
if err := rows.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTaskLocation = `-- name: UpdateTaskLocation :one
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position
`
type UpdateTaskLocationParams struct {
TaskID uuid.UUID `json:"task_id"`
TaskGroupID uuid.UUID `json:"task_group_id"`
Position float64 `json:"position"`
}
func (q *Queries) UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error) {
row := q.db.QueryRowContext(ctx, updateTaskLocation, arg.TaskID, arg.TaskGroupID, arg.Position)
var i Task
err := row.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
)
return i, err
}
const updateTaskName = `-- name: UpdateTaskName :one
UPDATE task SET name = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position
`
type UpdateTaskNameParams struct {
TaskID uuid.UUID `json:"task_id"`
Name string `json:"name"`
}
func (q *Queries) UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error) {
row := q.db.QueryRowContext(ctx, updateTaskName, arg.TaskID, arg.Name)
var i Task
err := row.Scan(
&i.TaskID,
&i.TaskGroupID,
&i.CreatedAt,
&i.Name,
&i.Position,
)
return i, err
}

107
api/pg/task_group.sql.go Normal file
View File

@ -0,0 +1,107 @@
// Code generated by sqlc. DO NOT EDIT.
// source: task_group.sql
package pg
import (
"context"
"time"
"github.com/google/uuid"
)
const createTaskGroup = `-- name: CreateTaskGroup :one
INSERT INTO task_group (project_id, created_at, name, position)
VALUES($1, $2, $3, $4) RETURNING task_group_id, project_id, created_at, name, position
`
type CreateTaskGroupParams struct {
ProjectID uuid.UUID `json:"project_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Position float64 `json:"position"`
}
func (q *Queries) CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error) {
row := q.db.QueryRowContext(ctx, createTaskGroup,
arg.ProjectID,
arg.CreatedAt,
arg.Name,
arg.Position,
)
var i TaskGroup
err := row.Scan(
&i.TaskGroupID,
&i.ProjectID,
&i.CreatedAt,
&i.Name,
&i.Position,
)
return i, err
}
const getAllTaskGroups = `-- name: GetAllTaskGroups :many
SELECT task_group_id, project_id, created_at, name, position FROM task_group
`
func (q *Queries) GetAllTaskGroups(ctx context.Context) ([]TaskGroup, error) {
rows, err := q.db.QueryContext(ctx, getAllTaskGroups)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TaskGroup
for rows.Next() {
var i TaskGroup
if err := rows.Scan(
&i.TaskGroupID,
&i.ProjectID,
&i.CreatedAt,
&i.Name,
&i.Position,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTaskGroupsForProject = `-- name: GetTaskGroupsForProject :many
SELECT task_group_id, project_id, created_at, name, position FROM task_group WHERE project_id = $1
`
func (q *Queries) GetTaskGroupsForProject(ctx context.Context, projectID uuid.UUID) ([]TaskGroup, error) {
rows, err := q.db.QueryContext(ctx, getTaskGroupsForProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TaskGroup
for rows.Next() {
var i TaskGroup
if err := rows.Scan(
&i.TaskGroupID,
&i.ProjectID,
&i.CreatedAt,
&i.Name,
&i.Position,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

122
api/pg/team.sql.go Normal file
View File

@ -0,0 +1,122 @@
// Code generated by sqlc. DO NOT EDIT.
// source: team.sql
package pg
import (
"context"
"time"
"github.com/google/uuid"
)
const createTeam = `-- name: CreateTeam :one
INSERT INTO team (organization_id, created_at, name) VALUES ($1, $2, $3) RETURNING team_id, created_at, name, organization_id
`
type CreateTeamParams struct {
OrganizationID uuid.UUID `json:"organization_id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
}
func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) {
row := q.db.QueryRowContext(ctx, createTeam, arg.OrganizationID, arg.CreatedAt, arg.Name)
var i Team
err := row.Scan(
&i.TeamID,
&i.CreatedAt,
&i.Name,
&i.OrganizationID,
)
return i, err
}
const deleteTeamByID = `-- name: DeleteTeamByID :exec
DELETE FROM team WHERE team_id = $1
`
func (q *Queries) DeleteTeamByID(ctx context.Context, teamID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteTeamByID, teamID)
return err
}
const getAllTeams = `-- name: GetAllTeams :many
SELECT team_id, created_at, name, organization_id FROM team
`
func (q *Queries) GetAllTeams(ctx context.Context) ([]Team, error) {
rows, err := q.db.QueryContext(ctx, getAllTeams)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Team
for rows.Next() {
var i Team
if err := rows.Scan(
&i.TeamID,
&i.CreatedAt,
&i.Name,
&i.OrganizationID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTeamByID = `-- name: GetTeamByID :one
SELECT team_id, created_at, name, organization_id FROM team WHERE team_id = $1
`
func (q *Queries) GetTeamByID(ctx context.Context, teamID uuid.UUID) (Team, error) {
row := q.db.QueryRowContext(ctx, getTeamByID, teamID)
var i Team
err := row.Scan(
&i.TeamID,
&i.CreatedAt,
&i.Name,
&i.OrganizationID,
)
return i, err
}
const getTeamsForOrganization = `-- name: GetTeamsForOrganization :many
SELECT team_id, created_at, name, organization_id FROM team WHERE organization_id = $1
`
func (q *Queries) GetTeamsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]Team, error) {
rows, err := q.db.QueryContext(ctx, getTeamsForOrganization, organizationID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Team
for rows.Next() {
var i Team
if err := rows.Scan(
&i.TeamID,
&i.CreatedAt,
&i.Name,
&i.OrganizationID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

76
api/pg/token.sql.go Normal file
View File

@ -0,0 +1,76 @@
// Code generated by sqlc. DO NOT EDIT.
// source: token.sql
package pg
import (
"context"
"time"
"github.com/google/uuid"
)
const createRefreshToken = `-- name: CreateRefreshToken :one
INSERT INTO refresh_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING token_id, user_id, created_at, expires_at
`
type CreateRefreshTokenParams struct {
UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) {
row := q.db.QueryRowContext(ctx, createRefreshToken, arg.UserID, arg.CreatedAt, arg.ExpiresAt)
var i RefreshToken
err := row.Scan(
&i.TokenID,
&i.UserID,
&i.CreatedAt,
&i.ExpiresAt,
)
return i, err
}
const deleteExpiredTokens = `-- name: DeleteExpiredTokens :exec
DELETE FROM refresh_token WHERE expires_at <= NOW()
`
func (q *Queries) DeleteExpiredTokens(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteExpiredTokens)
return err
}
const deleteRefreshTokenByID = `-- name: DeleteRefreshTokenByID :exec
DELETE FROM refresh_token WHERE token_id = $1
`
func (q *Queries) DeleteRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteRefreshTokenByID, tokenID)
return err
}
const deleteRefreshTokenByUserID = `-- name: DeleteRefreshTokenByUserID :exec
DELETE FROM refresh_token WHERE user_id = $1
`
func (q *Queries) DeleteRefreshTokenByUserID(ctx context.Context, userID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteRefreshTokenByUserID, userID)
return err
}
const getRefreshTokenByID = `-- name: GetRefreshTokenByID :one
SELECT token_id, user_id, created_at, expires_at FROM refresh_token WHERE token_id = $1
`
func (q *Queries) GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error) {
row := q.db.QueryRowContext(ctx, getRefreshTokenByID, tokenID)
var i RefreshToken
err := row.Scan(
&i.TokenID,
&i.UserID,
&i.CreatedAt,
&i.ExpiresAt,
)
return i, err
}

115
api/pg/user_accounts.sql.go Normal file
View File

@ -0,0 +1,115 @@
// Code generated by sqlc. DO NOT EDIT.
// source: user_accounts.sql
package pg
import (
"context"
"time"
"github.com/google/uuid"
)
const createUserAccount = `-- name: CreateUserAccount :one
INSERT INTO user_account(display_name, email, username, created_at, password_hash)
VALUES ($1, $2, $3, $4, $5)
RETURNING user_id, created_at, display_name, email, username, password_hash
`
type CreateUserAccountParams struct {
DisplayName string `json:"display_name"`
Email string `json:"email"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
PasswordHash string `json:"password_hash"`
}
func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, createUserAccount,
arg.DisplayName,
arg.Email,
arg.Username,
arg.CreatedAt,
arg.PasswordHash,
)
var i UserAccount
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.DisplayName,
&i.Email,
&i.Username,
&i.PasswordHash,
)
return i, err
}
const getAllUserAccounts = `-- name: GetAllUserAccounts :many
SELECT user_id, created_at, display_name, email, username, password_hash FROM user_account
`
func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) {
rows, err := q.db.QueryContext(ctx, getAllUserAccounts)
if err != nil {
return nil, err
}
defer rows.Close()
var items []UserAccount
for rows.Next() {
var i UserAccount
if err := rows.Scan(
&i.UserID,
&i.CreatedAt,
&i.DisplayName,
&i.Email,
&i.Username,
&i.PasswordHash,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserAccountByID = `-- name: GetUserAccountByID :one
SELECT user_id, created_at, display_name, email, username, password_hash FROM user_account WHERE user_id = $1
`
func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, getUserAccountByID, userID)
var i UserAccount
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.DisplayName,
&i.Email,
&i.Username,
&i.PasswordHash,
)
return i, err
}
const getUserAccountByUsername = `-- name: GetUserAccountByUsername :one
SELECT user_id, created_at, display_name, email, username, password_hash FROM user_account WHERE username = $1
`
func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, getUserAccountByUsername, username)
var i UserAccount
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.DisplayName,
&i.Email,
&i.Username,
&i.PasswordHash,
)
return i, err
}

View File

@ -0,0 +1,5 @@
-- name: GetAllOrganizations :many
SELECT * FROM organization;
-- name: CreateOrganization :one
INSERT INTO organization (created_at, name) VALUES ($1, $2) RETURNING *;

11
api/query/project.sql Normal file
View File

@ -0,0 +1,11 @@
-- name: GetAllProjects :many
SELECT * FROM project;
-- name: GetAllProjectsForTeam :many
SELECT * FROM project WHERE team_id = $1;
-- name: GetProjectByID :one
SELECT * FROM project WHERE project_id = $1;
-- name: CreateProject :one
INSERT INTO project(team_id, created_at, name) VALUES ($1, $2, $3) RETURNING *;

18
api/query/task.sql Normal file
View File

@ -0,0 +1,18 @@
-- name: CreateTask :one
INSERT INTO task (task_group_id, created_at, name, position)
VALUES($1, $2, $3, $4) RETURNING *;
-- name: GetTasksForTaskGroupID :many
SELECT * FROM task WHERE task_group_id = $1;
-- name: GetAllTasks :many
SELECT * FROM task;
-- name: UpdateTaskLocation :one
UPDATE task SET task_group_id = $2, position = $3 WHERE task_id = $1 RETURNING *;
-- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1;
-- name: UpdateTaskName :one
UPDATE task SET name = $2 WHERE task_id = $1 RETURNING *;

9
api/query/task_group.sql Normal file
View File

@ -0,0 +1,9 @@
-- name: CreateTaskGroup :one
INSERT INTO task_group (project_id, created_at, name, position)
VALUES($1, $2, $3, $4) RETURNING *;
-- name: GetTaskGroupsForProject :many
SELECT * FROM task_group WHERE project_id = $1;
-- name: GetAllTaskGroups :many
SELECT * FROM task_group;

14
api/query/team.sql Normal file
View File

@ -0,0 +1,14 @@
-- name: GetAllTeams :many
SELECT * FROM team;
-- name: GetTeamByID :one
SELECT * FROM team WHERE team_id = $1;
-- name: CreateTeam :one
INSERT INTO team (organization_id, created_at, name) VALUES ($1, $2, $3) RETURNING *;
-- name: DeleteTeamByID :exec
DELETE FROM team WHERE team_id = $1;
-- name: GetTeamsForOrganization :many
SELECT * FROM team WHERE organization_id = $1;

14
api/query/token.sql Normal file
View File

@ -0,0 +1,14 @@
-- name: GetRefreshTokenByID :one
SELECT * FROM refresh_token WHERE token_id = $1;
-- name: CreateRefreshToken :one
INSERT INTO refresh_token (user_id, created_at, expires_at) VALUES ($1, $2, $3) RETURNING *;
-- name: DeleteRefreshTokenByID :exec
DELETE FROM refresh_token WHERE token_id = $1;
-- name: DeleteRefreshTokenByUserID :exec
DELETE FROM refresh_token WHERE user_id = $1;
-- name: DeleteExpiredTokens :exec
DELETE FROM refresh_token WHERE expires_at <= NOW();

View File

@ -0,0 +1,13 @@
-- name: GetUserAccountByID :one
SELECT * FROM user_account WHERE user_id = $1;
-- name: GetAllUserAccounts :many
SELECT * FROM user_account;
-- name: GetUserAccountByUsername :one
SELECT * FROM user_account WHERE username = $1;
-- name: CreateUserAccount :one
INSERT INTO user_account(display_name, email, username, created_at, password_hash)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;

113
api/router/auth.go Normal file
View File

@ -0,0 +1,113 @@
package router
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi"
"github.com/google/uuid"
"github.com/jordanknott/project-citadel/api/pg"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
)
var jwtKey = []byte("citadel_test_key")
type authResource struct{}
func (h *CitadelHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("refreshToken")
if err != nil {
if err == http.ErrNoCookie {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusBadRequest)
return
}
refreshTokenID := uuid.MustParse(c.Value)
token, err := h.repo.GetRefreshTokenByID(r.Context(), refreshTokenID)
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(), pg.CreateRefreshTokenParams{token.UserID, refreshCreatedAt, refreshExpiresAt})
err = h.repo.DeleteRefreshTokenByID(r.Context(), token.TokenID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
accessTokenString, err := NewAccessToken("1")
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{AccessToken: accessTokenString})
}
func (h *CitadelHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
var requestData LoginRequestData
err := json.NewDecoder(r.Body).Decode(&requestData)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
log.Debug("bad request body")
return
}
user, err := h.repo.GetUserAccountByUsername(r.Context(), requestData.Username)
if err != nil {
log.WithFields(log.Fields{
"username": requestData.Username,
}).Warn("user account not found")
w.WriteHeader(http.StatusUnauthorized)
return
}
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(requestData.Password))
if err != nil {
log.WithFields(log.Fields{
"password": requestData.Password,
"password_hash": user.PasswordHash,
}).Warn("password incorrect")
w.WriteHeader(http.StatusUnauthorized)
return
}
userID := uuid.MustParse("0183d9ab-d0ed-4c9b-a3df-77a0cdd93dca")
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), pg.CreateRefreshTokenParams{userID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := NewAccessToken("1")
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})
}
func (rs authResource) Routes(citadelHandler CitadelHandler) chi.Router {
r := chi.NewRouter()
r.Post("/login", citadelHandler.LoginHandler)
r.Post("/refresh_token", citadelHandler.RefreshTokenHandler)
return r
}

13
api/router/errors.go Normal file
View File

@ -0,0 +1,13 @@
package router
type ErrExpiredToken struct{}
func (r *ErrExpiredToken) Error() string {
return "token is expired"
}
type ErrMalformedToken struct{}
func (r *ErrMalformedToken) Error() string {
return "token is malformed"
}

7
api/router/handlers.go Normal file
View File

@ -0,0 +1,7 @@
package router
import "github.com/jordanknott/project-citadel/api/pg"
type CitadelHandler struct {
repo pg.Repository
}

93
api/router/logger.go Normal file
View File

@ -0,0 +1,93 @@
package router
import (
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/middleware"
"github.com/sirupsen/logrus"
)
// StructuredLogger is a simple, but powerful implementation of a custom structured
// logger backed on logrus. I encourage users to copy it, adapt it and make it their
// own. Also take a look at https://github.com/pressly/lg for a dedicated pkg based
// on this work, designed for context-based http routers.
func NewStructuredLogger(logger *logrus.Logger) func(next http.Handler) http.Handler {
return middleware.RequestLogger(&StructuredLogger{logger})
}
type StructuredLogger struct {
Logger *logrus.Logger
}
func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry {
entry := &StructuredLoggerEntry{Logger: logrus.NewEntry(l.Logger)}
logFields := logrus.Fields{}
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
logFields["req_id"] = reqID
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
logFields["http_scheme"] = scheme
logFields["http_proto"] = r.Proto
logFields["http_method"] = r.Method
logFields["remote_addr"] = r.RemoteAddr
logFields["user_agent"] = r.UserAgent()
logFields["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
entry.Logger = entry.Logger.WithFields(logFields)
return entry
}
type StructuredLoggerEntry struct {
Logger logrus.FieldLogger
}
func (l *StructuredLoggerEntry) Write(status, bytes int, elapsed time.Duration) {
l.Logger = l.Logger.WithFields(logrus.Fields{
"resp_status": status, "resp_bytes_length": bytes,
"resp_elapsed_ms": float64(elapsed.Nanoseconds()) / 1000000.0,
})
l.Logger.Infoln("request complete")
}
func (l *StructuredLoggerEntry) Panic(v interface{}, stack []byte) {
l.Logger = l.Logger.WithFields(logrus.Fields{
"stack": string(stack),
"panic": fmt.Sprintf("%+v", v),
})
}
// Helper methods used by the application to get the request-scoped
// logger entry and set additional fields between handlers.
//
// This is a useful pattern to use to set state on the entry as it
// passes through the handler chain, which at any point can be logged
// with a call to .Print(), .Info(), etc.
func GetLogEntry(r *http.Request) logrus.FieldLogger {
entry := middleware.GetLogEntry(r).(*StructuredLoggerEntry)
return entry.Logger
}
func LogEntrySetField(r *http.Request, key string, value interface{}) {
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
entry.Logger = entry.Logger.WithField(key, value)
}
}
func LogEntrySetFields(r *http.Request, fields map[string]interface{}) {
if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok {
entry.Logger = entry.Logger.WithFields(fields)
}
}

44
api/router/middleware.go Normal file
View File

@ -0,0 +1,44 @@
package router
import (
"context"
"net/http"
"strings"
log "github.com/sirupsen/logrus"
)
func AuthenticationMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bearerTokenRaw := r.Header.Get("Authorization")
splitToken := strings.Split(bearerTokenRaw, "Bearer")
if len(splitToken) != 2 {
w.WriteHeader(http.StatusBadRequest)
return
}
accessTokenString := strings.TrimSpace(splitToken[1])
accessClaims, err := ValidateAccessToken(accessTokenString)
if err != nil {
if _, ok := err.(*ErrExpiredToken); ok {
w.Write([]byte(`{
"data": {},
"errors": [
{
"extensions": {
"code": "UNAUTHENTICATED"
}
}
]
}`))
return
}
log.Error(err)
w.WriteHeader(http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), "accessClaims", accessClaims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

28
api/router/models.go Normal file
View File

@ -0,0 +1,28 @@
package router
import (
"github.com/dgrijalva/jwt-go"
)
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 LoginResponseData struct {
AccessToken string `json:"accessToken"`
}
type RefreshTokenResponseData struct {
AccessToken string `json:"accessToken"`
}

61
api/router/router.go Normal file
View File

@ -0,0 +1,61 @@
package router
import (
"net/http"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus"
"github.com/jordanknott/project-citadel/api/graph"
"github.com/jordanknott/project-citadel/api/pg"
)
func (h *CitadelHandler) PingHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
}
func NewRouter(db *sqlx.DB) (chi.Router, error) {
formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05"
formatter.FullTimestamp = true
routerLogger := log.New()
routerLogger.SetLevel(log.WarnLevel)
routerLogger.Formatter = formatter
r := chi.NewRouter()
cors := cors.New(cors.Options{
// AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts
AllowedOrigins: []string{"*"},
// AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "Cookie"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
})
r.Use(cors.Handler)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(NewStructuredLogger(routerLogger))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
repository := pg.NewRepository(db)
citadelHandler := CitadelHandler{repository}
r.Group(func(mux chi.Router) {
mux.Mount("/auth", authResource{}.Routes(citadelHandler))
mux.Handle("/__graphql", graph.NewPlaygroundHandler("/graphql"))
})
r.Group(func(mux chi.Router) {
mux.Use(AuthenticationMiddleware)
mux.Get("/ping", citadelHandler.PingHandler)
mux.Handle("/graphql", graph.NewHandler(repository))
})
return r, nil
}

77
api/router/tokens.go Normal file
View File

@ -0,0 +1,77 @@
package router
import (
"time"
"github.com/dgrijalva/jwt-go"
log "github.com/sirupsen/logrus"
)
func NewAccessToken(userID string) (string, error) {
accessExpirationTime := time.Now().Add(5 * time.Second)
accessClaims := &AccessTokenClaims{
UserID: userID,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString(jwtKey)
if err != nil {
return "", err
}
return accessTokenString, nil
}
func NewAccessTokenCustomExpiration(userID string, dur time.Duration) (string, error) {
accessExpirationTime := time.Now().Add(dur)
accessClaims := &AccessTokenClaims{
UserID: userID,
StandardClaims: jwt.StandardClaims{ExpiresAt: accessExpirationTime.Unix()},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString(jwtKey)
if err != nil {
return "", err
}
return accessTokenString, nil
}
func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) {
accessClaims := &AccessTokenClaims{}
accessToken, err := jwt.ParseWithClaims(accessTokenString, accessClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
if accessToken.Valid {
log.WithFields(log.Fields{
"token": accessTokenString,
"timeToExpire": time.Unix(accessClaims.ExpiresAt, 0),
}).Info("token is valid")
return *accessClaims, nil
}
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&jwt.ValidationErrorMalformed != 0 {
return AccessTokenClaims{}, &ErrMalformedToken{}
} else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 {
return AccessTokenClaims{}, &ErrExpiredToken{}
}
}
return AccessTokenClaims{}, err
}
func NewRefreshToken(userID string) (string, time.Time, error) {
refreshExpirationTime := time.Now().Add(24 * time.Hour)
refreshClaims := &RefreshTokenClaims{
UserID: userID,
StandardClaims: jwt.StandardClaims{ExpiresAt: refreshExpirationTime.Unix()},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString(jwtKey)
if err != nil {
return "", time.Time{}, err
}
return refreshTokenString, refreshExpirationTime, nil
}

26
api/scripts/auth Executable file
View File

@ -0,0 +1,26 @@
#!/usr/bin/python3
# vi: ft=python
import click
import requests
@click.command()
def authenticate():
s = requests.Session()
r = s.post('http://localhost:3333/auth/login', json={
'username': 'hello',
'password': 'test',
})
if r.status_code != 200:
print('issue during login status_code={}'.format(r.status_code))
return
access_code = r.json()['access_token']
r = s.get('http://localhost:3333/ping', headers={
'Authorization': 'Bearer {}'.format(access_code)
})
print(r.status_code)
print(r.text)
if __name__ == "__main__":
authenticate()

10
api/sqlc.yaml Normal file
View File

@ -0,0 +1,10 @@
version: "1"
packages:
- name: "pg"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: true
path: "pg"
queries: "./query/"
schema: "./migrations/"

9
web/.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
end_of_line = lf
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

62
web/.eslintrc.json Normal file
View File

@ -0,0 +1,62 @@
{
"env": {
"browser": true,
"es6": true
},
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": ["react", "@typescript-eslint", "prettier"],
"extends": [
"plugin:react/recommended",
"airbnb",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"prettier/prettier": "error",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
"react/prop-types": 0,
"react/jsx-props-no-spreading": "off",
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"mjs": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
],
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [".storybook/**", "src/shared/components/**/*.stories.tsx"]
}
]
},
"settings": {
"import/resolver": {
"node": {
"paths": ["src"],
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
}
}

23
web/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

7
web/.prettierrc.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
semi: true,
trailingComma: "all",
singleQuote: true,
printWidth: 120,
tabWidth: 2
};

18
web/.storybook/main.js Normal file
View File

@ -0,0 +1,18 @@
const path = require('path');
module.exports = {
stories: ['../src/shared/components/**/*.stories.tsx'],
addons: [
'@storybook/addon-actions/register',
'@storybook/addon-links',
'@storybook/addon-storysource',
'@storybook/addon-knobs/register',
'@storybook/addon-docs/register',
'@storybook/addon-viewport/register',
'@storybook/addon-backgrounds/register',
],
webpackFinal: async config => {
config.resolve.modules.push(path.resolve(__dirname, '../src'));
return config;
},
};

44
web/README.md Normal file
View File

@ -0,0 +1,44 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

95
web/package.json Normal file
View File

@ -0,0 +1,95 @@
{
"name": "app",
"version": "0.1.0",
"private": true,
"dependencies": {
"@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",
"@storybook/addon-backgrounds": "^5.3.17",
"@storybook/addon-docs": "^5.3.17",
"@storybook/addon-knobs": "^5.3.17",
"@storybook/addon-storysource": "^5.3.17",
"@storybook/addon-viewport": "^5.3.17",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/color": "^3.0.1",
"@types/jest": "^24.0.0",
"@types/lodash": "^4.14.149",
"@types/node": "^12.0.0",
"@types/react": "^16.9.21",
"@types/react-beautiful-dnd": "^12.1.1",
"@types/react-dom": "^16.9.5",
"@types/react-router": "^5.1.4",
"@types/react-router-dom": "^5.1.3",
"@types/styled-components": "^5.0.0",
"apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8",
"apollo-link": "^1.2.13",
"apollo-link-error": "^1.1.12",
"apollo-link-http": "^1.5.16",
"apollo-link-state": "^0.4.2",
"apollo-utilities": "^1.3.3",
"color": "^3.1.2",
"graphql": "^14.6.0",
"graphql-tag": "^2.10.3",
"history": "^4.10.1",
"lodash": "^4.17.15",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-autosize-textarea": "^7.0.0",
"react-beautiful-dnd": "^13.0.0",
"react-dom": "^16.12.0",
"react-hook-form": "^5.2.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",
"styled-components": "^5.0.1",
"typescript": "~3.7.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"storybook": "start-storybook -p 9009 -s public",
"build-storybook": "build-storybook -s public"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@storybook/addon-actions": "^5.3.13",
"@storybook/addon-links": "^5.3.13",
"@storybook/addons": "^5.3.13",
"@storybook/preset-create-react-app": "^1.5.2",
"@storybook/react": "^5.3.13",
"@typescript-eslint/eslint-plugin": "^2.20.0",
"@typescript-eslint/parser": "^2.20.0",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-react": "^7.18.3",
"eslint-plugin-react-hooks": "^1.7.0",
"prettier": "^1.19.1"
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

43
web/public/index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
web/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
web/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
web/public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
web/public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

110
web/src/App/BaseStyles.ts Normal file
View File

@ -0,0 +1,110 @@
import { createGlobalStyle } from 'styled-components';
import { color, font, mixin } from 'shared/utils/styles';
export default createGlobalStyle`
html, body, #root {
height: 100%;
min-height: 100%;
min-width: 768px;
}
body {
color: ${color.textDarkest};
-webkit-tap-highlight-color: transparent;
line-height: 1.2;
${font.size(16)}
${font.regular}
}
#root {
display: flex;
flex-direction: column;
}
button,
input,
optgroup,
select,
textarea {
${font.regular}
}
*, *:after, *:before, input[type="search"] {
box-sizing: border-box;
}
a {
color: inherit;
text-decoration: none;
}
ul {
list-style: none;
}
ul, li, ol, dd, h1, h2, h3, h4, h5, h6, p {
padding: 0;
margin: 0;
}
h1, h2, h3, h4, h5, h6, strong {
${font.bold}
}
button {
background: none;
border: none;
}
/* Workaround for IE11 focus highlighting for select elements */
select::-ms-value {
background: none;
color: #42413d;
}
[role="button"], button, input, select, textarea {
outline: none;
&:focus {
outline: none;
}
&:disabled {
opacity: 1;
}
}
[role="button"], button, input, textarea {
appearance: none;
}
select:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 #000;
}
select::-ms-expand {
display: none;
}
select option {
color: ${color.textDarkest};
}
p {
line-height: 1.4285;
a {
${mixin.link()}
}
}
textarea {
line-height: 1.4285;
}
body, select {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
touch-action: manipulation;
}
${mixin.placeholderColor(color.textLight)}
`;

26
web/src/App/Navbar.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from 'react';
import { Home, Stack } from 'shared/icons';
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from 'shared/components/Navbar';
import { Link } from 'react-router-dom';
const GlobalNavbar = () => {
return (
<Navbar>
<PrimaryLogo />
<ButtonContainer>
<Link to="/">
<ActionButton name="Home">
<Home size={28} color="#c2c6dc" />
</ActionButton>
</Link>
<Link to="/projects">
<ActionButton name="Projects">
<Stack size={28} color="#c2c6dc" />
</ActionButton>
</Link>
</ButtonContainer>
</Navbar>
);
};
export default GlobalNavbar;

View File

@ -0,0 +1,152 @@
import { createGlobalStyle } from 'styled-components';
/** DO NOT ALTER THIS FILE. It is a copy of https://necolas.github.io/normalize.css/ */
export default createGlobalStyle`
html {
line-height: 1.15;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}
main {
display: block;
}
h1 {
font-size: 2em;
margin: 0.67em 0;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
pre {
font-family: monospace, monospace;
font-size: 1em;
}
a {
background-color: transparent;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none;
}
`;

23
web/src/App/Routes.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from 'react';
import { Router, Switch, Route } from 'react-router-dom';
import * as H from 'history';
import Projects from 'Projects';
import Project from 'Projects/Project';
import Login from 'Auth';
type RoutesProps = {
history: H.History;
};
const Routes = ({ history }: RoutesProps) => (
<Router history={history}>
<Switch>
<Route exact path="/projects" component={Projects} />
<Route exact path="/projects/:projectId" component={Project} />
<Route exact path="/login" component={Login} />
</Switch>
</Router>
);
export default Routes;

26
web/src/App/TopNavbar.tsx Normal file
View File

@ -0,0 +1,26 @@
import React, { useState } from 'react';
import TopNavbar from 'shared/components/TopNavbar';
import DropdownMenu from 'shared/components/DropdownMenu';
const GlobalTopNavbar: React.FC = () => {
const [menu, setMenu] = useState({
top: 0,
left: 0,
isOpen: false,
});
const onProfileClick = (bottom: number, right: number) => {
setMenu({
isOpen: !menu.isOpen,
left: right,
top: bottom,
});
};
return (
<>
<TopNavbar onNotificationClick={() => console.log('beep')} onProfileClick={onProfileClick} />
{menu.isOpen && <DropdownMenu left={menu.left} top={menu.top} />}
</>
);
};
export default GlobalTopNavbar;

43
web/src/App/index.tsx Normal file
View File

@ -0,0 +1,43 @@
import React, { useState, useEffect } from 'react';
import { createBrowserHistory } from 'history';
import { setAccessToken } from 'shared/utils/accessToken';
import NormalizeStyles from './NormalizeStyles';
import BaseStyles from './BaseStyles';
import Routes from './Routes';
const history = createBrowserHistory();
const App = () => {
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('http://localhost:3333/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 400) {
history.replace('/login');
} else {
const response: RefreshTokenResponse = await x.json();
const { accessToken } = response;
setAccessToken(accessToken);
}
// }
setLoading(false);
});
}, []);
if (loading) {
return <div>loading...</div>;
}
return (
<>
<NormalizeStyles />
<BaseStyles />
<Routes history={history} />
</>
);
};
export default App;

13
web/src/Auth/Styles.ts Normal file
View 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%;
`;

62
web/src/Auth/index.tsx Normal file
View File

@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useHistory } from 'react-router';
import { setAccessToken } from 'shared/utils/accessToken';
import Login from 'shared/components/Login';
import { Container, LoginWrapper } from './Styles';
const Auth = () => {
const [invalidLoginAttempt, setInvalidLoginAttempt] = useState(0);
const history = useHistory();
const login = (
data: LoginFormData,
setComplete: (val: boolean) => void,
setError: (field: string, eType: string, message: string) => void,
) => {
fetch('http://localhost:3333/auth/login', {
credentials: 'include',
method: 'POST',
body: JSON.stringify({
username: data.username,
password: data.password,
}),
}).then(async x => {
if (x.status === 401) {
setInvalidLoginAttempt(invalidLoginAttempt + 1);
setError('username', 'invalid', 'Invalid username');
setError('password', 'invalid', 'Invalid password');
setComplete(true);
} else {
const response = await x.json();
const { accessToken } = response;
setAccessToken(accessToken);
setComplete(true);
history.push('/');
}
});
};
useEffect(() => {
fetch('http://localhost:3333/auth/refresh_token', {
method: 'POST',
credentials: 'include',
}).then(async x => {
const { status } = x;
if (status === 200) {
history.replace('/projects');
}
});
}, []);
return (
<Container>
<LoginWrapper>
<Login onSubmit={login} />
</LoginWrapper>
</Container>
);
};
export default Auth;

View File

@ -0,0 +1,322 @@
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import { useQuery, useMutation } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import { useParams } from 'react-router-dom';
import Navbar from 'App/Navbar';
import TopNavbar from 'App/TopNavbar';
import Lists from 'shared/components/Lists';
import QuickCardEditor from 'shared/components/QuickCardEditor';
interface ColumnState {
[key: string]: TaskGroup;
}
interface TaskState {
[key: string]: RemoteTask;
}
interface State {
columns: ColumnState;
tasks: TaskState;
}
interface QuickCardEditorState {
isOpen: boolean;
left: number;
top: number;
task?: RemoteTask;
}
const MainContent = styled.div`
padding: 0 0 50px 100px;
height: 100%;
background: #262c49;
`;
const Wrapper = styled.div`
font-size: 16px;
background-color: red;
`;
const Title = styled.span`
text-align: center;
font-size: 24px;
color: #fff;
`;
interface ProjectData {
findProject: Project;
}
interface UpdateTaskLocationData {
updateTaskLocation: Task;
}
interface UpdateTaskLocationVars {
taskID: string;
taskGroupID: string;
position: number;
}
interface ProjectVars {
projectId: string;
}
interface CreateTaskVars {
taskGroupID: string;
name: string;
position: number;
}
interface CreateTaskData {
createTask: RemoteTask;
}
interface ProjectParams {
projectId: string;
}
interface DeleteTaskData {
deleteTask: { taskID: string };
}
interface DeleteTaskVars {
taskID: string;
}
interface UpdateTaskNameData {
updateTaskName: RemoteTask;
}
interface UpdateTaskNameVars {
taskID: string;
name: string;
}
const UPDATE_TASK_NAME = gql`
mutation updateTaskName($taskID: String!, $name: String!) {
updateTaskName(input: { taskID: $taskID, name: $name }) {
taskID
name
position
}
}
`;
const GET_PROJECT = gql`
query getProject($projectId: String!) {
findProject(input: { projectId: $projectId }) {
name
taskGroups {
taskGroupID
name
position
tasks {
taskID
name
position
}
}
}
}
`;
const CREATE_TASK = gql`
mutation createTask($taskGroupID: String!, $name: String!, $position: Float!) {
createTask(input: { taskGroupID: $taskGroupID, name: $name, position: $position }) {
taskID
taskGroupID
name
position
}
}
`;
const DELETE_TASK = gql`
mutation deleteTask($taskID: String!) {
deleteTask(input: { taskID: $taskID }) {
taskID
}
}
`;
const UPDATE_TASK_LOCATION = gql`
mutation updateTaskLocation($taskID: String!, $taskGroupID: String!, $position: Float!) {
updateTaskLocation(input: { taskID: $taskID, taskGroupID: $taskGroupID, position: $position }) {
taskID
createdAt
name
position
}
}
`;
const initialState: State = { tasks: {}, columns: {} };
const initialQuickCardEditorState: QuickCardEditorState = { isOpen: false, top: 0, left: 0 };
const Project = () => {
const { projectId } = useParams<ProjectParams>();
const [listsData, setListsData] = useState(initialState);
const [quickCardEditor, setQuickCardEditor] = useState(initialQuickCardEditorState);
const [updateTaskLocation, updateTaskLocationData] = useMutation<UpdateTaskLocationData, UpdateTaskLocationVars>(
UPDATE_TASK_LOCATION,
);
const [createTask, createTaskData] = useMutation<CreateTaskData, CreateTaskVars>(CREATE_TASK, {
onCompleted: newTaskData => {
const newListsData = {
...listsData,
tasks: {
...listsData.tasks,
[newTaskData.createTask.taskID]: {
taskGroupID: newTaskData.createTask.taskGroupID,
taskID: newTaskData.createTask.taskID,
name: newTaskData.createTask.name,
position: newTaskData.createTask.position,
labels: [],
},
},
};
setListsData(newListsData);
},
});
const [deleteTask, deleteTaskData] = useMutation<DeleteTaskData, DeleteTaskVars>(DELETE_TASK, {
onCompleted: deletedTask => {
const { [deletedTask.deleteTask.taskID]: removedTask, ...remainingTasks } = listsData.tasks;
const newListsData = {
...listsData,
tasks: remainingTasks,
};
setListsData(newListsData);
},
});
const [updateTaskName, updateTaskNameData] = useMutation<UpdateTaskNameData, UpdateTaskNameVars>(UPDATE_TASK_NAME, {
onCompleted: newTaskData => {
const newListsData = {
...listsData,
tasks: {
...listsData.tasks,
[newTaskData.updateTaskName.taskID]: {
...listsData.tasks[newTaskData.updateTaskName.taskID],
name: newTaskData.updateTaskName.name,
},
},
};
setListsData(newListsData);
},
});
const { loading, data } = useQuery<ProjectData, ProjectVars>(GET_PROJECT, {
variables: { projectId },
onCompleted: newData => {
let newListsData: State = { tasks: {}, columns: {} };
newData.findProject.taskGroups.forEach((taskGroup: TaskGroup) => {
newListsData.columns[taskGroup.taskGroupID] = {
taskGroupID: taskGroup.taskGroupID,
name: taskGroup.name,
position: taskGroup.position,
tasks: [],
};
taskGroup.tasks.forEach((task: RemoteTask) => {
newListsData.tasks[task.taskID] = {
taskID: task.taskID,
taskGroupID: taskGroup.taskGroupID,
name: task.name,
position: task.position,
labels: [],
};
});
});
setListsData(newListsData);
},
});
const onCardDrop = (droppedTask: any) => {
updateTaskLocation({
variables: { taskID: droppedTask.taskID, taskGroupID: droppedTask.taskGroupID, position: droppedTask.position },
});
const newState = {
...listsData,
tasks: {
...listsData.tasks,
[droppedTask.taskID]: droppedTask,
},
};
setListsData(newState);
};
const onListDrop = (droppedColumn: any) => {
const newState = {
...listsData,
columns: {
...listsData.columns,
[droppedColumn.taskGroupID]: droppedColumn,
},
};
setListsData(newState);
};
const onCardCreate = (taskGroupID: string, name: string) => {
const taskGroupTasks = Object.values(listsData.tasks).filter(
(task: RemoteTask) => task.taskGroupID === taskGroupID,
);
var position = 65535;
console.log(taskGroupID);
console.log(taskGroupTasks);
if (taskGroupTasks.length !== 0) {
const [lastTask] = taskGroupTasks.sort((a: any, b: any) => a.position - b.position).slice(-1);
console.log(`last tasks position ${lastTask.position}`);
position = Math.ceil(lastTask.position) * 2 + 1;
}
createTask({ variables: { taskGroupID: taskGroupID, name: name, position: position } });
};
const onQuickEditorOpen = (e: ContextMenuEvent) => {
const task = Object.values(listsData.tasks).find(task => task.taskID === e.cardId);
setQuickCardEditor({
top: e.top,
left: e.left,
isOpen: true,
task,
});
};
if (loading) {
return <Wrapper>Loading</Wrapper>;
}
if (data) {
return (
<>
<Navbar />
<MainContent>
<TopNavbar />
<Title>{data.findProject.name}</Title>
<Lists
onQuickEditorOpen={onQuickEditorOpen}
onCardCreate={onCardCreate}
{...listsData}
onCardDrop={onCardDrop}
onListDrop={onListDrop}
/>
</MainContent>
{quickCardEditor.isOpen && (
<QuickCardEditor
isOpen={true}
listId={quickCardEditor.task ? quickCardEditor.task.taskGroupID : ''}
cardId={quickCardEditor.task ? quickCardEditor.task.taskID : ''}
cardTitle={quickCardEditor.task ? quickCardEditor.task.name : ''}
onCloseEditor={() => setQuickCardEditor(initialQuickCardEditorState)}
onEditCard={(listId: string, cardId: string, cardName: string) =>
updateTaskName({ variables: { taskID: cardId, name: cardName } })
}
onOpenPopup={() => console.log()}
onArchiveCard={(listId: string, cardId: string) => deleteTask({ variables: { taskID: cardId } })}
labels={[]}
top={quickCardEditor.top}
left={quickCardEditor.left}
/>
)}
</>
);
}
return <Wrapper>Error</Wrapper>;
};
export default Project;

View File

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import { useQuery } from '@apollo/react-hooks';
import gql from 'graphql-tag';
import TopNavbar from 'App/TopNavbar';
import ProjectGridItem from 'shared/components/ProjectGridItem';
import { Link } from 'react-router-dom';
import Navbar from 'App/Navbar';
const MainContent = styled.div`
padding: 0 0 50px 80px;
height: 100%;
background: #262c49;
`;
const ProjectGrid = styled.div`
width: 60%;
margin: 25px auto;
display: flex;
align-items: center;
justify-content: center;
`;
const Wrapper = styled.div`
font-size: 16px;
background-color: red;
`;
interface ProjectData {
name: string;
organizations: Organization[];
}
const GET_PROJECTS = gql`
query getProjects {
organizations {
name
teams {
name
projects {
name
projectID
}
}
}
}
`;
const Projects = () => {
const { loading, data } = useQuery<ProjectData>(GET_PROJECTS);
console.log(loading, data);
if (loading) {
return <Wrapper>Loading</Wrapper>;
}
if (data) {
const { teams } = data.organizations[0];
const projects: Project[] = [];
teams.forEach(team =>
team.projects.forEach(project => {
projects.push({
taskGroups: [],
projectID: project.projectID,
teamTitle: team.name,
name: project.name,
color: '#aa62e3',
});
}),
);
return (
<>
<Navbar />
<MainContent>
<TopNavbar />
<ProjectGrid>
{projects.map(project => (
<Link to={`/projects/${project.projectID}/`}>
<ProjectGridItem project={project} />
</Link>
))}
</ProjectGrid>
</MainContent>
</>
);
}
return <Wrapper>Error</Wrapper>;
};
export default Projects;

65
web/src/citadel.d.ts vendored Normal file
View File

@ -0,0 +1,65 @@
type ContextMenuEvent = {
left: number;
top: number;
cardId: string;
listId: string;
};
interface RemoteTask {
taskID: string;
taskGroupID: string;
name: string;
position: number;
labels: Label[];
}
type TaskGroup = {
taskGroupID: string;
name: string;
position: number;
tasks: RemoteTask[];
};
type Project = {
projectID: string;
name: string;
color?: string;
teamTitle?: string;
taskGroups: TaskGroup[];
};
interface Organization {
name: string;
teams: Team[];
}
interface Team {
name: string;
projects: Project[];
}
type Label = {
labelId: string;
name: string;
color: string;
active: boolean;
};
type Task = {
title: string;
position: number;
};
type RefreshTokenResponse = {
accessToken: string;
};
type LoginFormData = {
username: string;
password: string;
};
type LoginProps = {
onSubmit: (
data: LoginFormData,
setComplete: (val: boolean) => void,
setError: (field: string, eType: string, message: string) => void,
) => void;
};

130
web/src/index.tsx Normal file
View File

@ -0,0 +1,130 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { ApolloProvider } from '@apollo/react-hooks';
import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
import { onError } from 'apollo-link-error';
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
import App from './App';
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
let isRefreshing = false;
let pendingRequests: any = [];
const resolvePendingRequests = () => {
pendingRequests.map((callback: any) => callback());
pendingRequests = [];
};
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
switch (err!.extensions!.code) {
case 'UNAUTHENTICATED':
// error code is set to UNAUTHENTICATED
// when AuthenticationError thrown in resolver
let forward$;
if (!isRefreshing) {
isRefreshing = true;
forward$ = fromPromise(
getNewToken()
.then((response: any) => {
// Store the new tokens for your auth link
setAccessToken(response.accessToken);
resolvePendingRequests();
return response.accessToken;
})
.catch((error: any) => {
pendingRequests = [];
// TODO
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return;
})
.finally(() => {
isRefreshing = false;
}),
).filter(value => Boolean(value));
} else {
// Will only emit once the Promise is resolved
forward$ = fromPromise(
new Promise(resolve => {
pendingRequests.push(() => resolve());
}),
);
}
return forward$.flatMap(() => forward(operation));
default:
// pass
}
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`);
// if you would also like to retry automatically on
// network errors, we recommend that you use
// apollo-link-retry
}
});
const requestLink = new ApolloLink(
(operation, forward) =>
new Observable((observer: any) => {
let handle: any;
Promise.resolve(operation)
.then((operation: any) => {
const accessToken = getAccessToken();
if (accessToken) {
operation.setContext({
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
}
})
.then(() => {
handle = forward(operation).subscribe({
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
})
.catch(observer.error.bind(observer));
return () => {
if (handle) handle.unsubscribe();
};
}),
);
const client = new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
);
if (networkError) console.log(`[Network error]: ${networkError}`);
}),
errorLink,
requestLink,
new HttpLink({
uri: 'http://localhost:3333/graphql',
credentials: 'same-origin',
}),
]),
cache: new InMemoryCache(),
});
ReactDOM.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
document.getElementById('root'),
);

1
web/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

5
web/src/setupTests.ts Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

View File

@ -0,0 +1,116 @@
import React, { useRef } from 'react';
import { action } from '@storybook/addon-actions';
import LabelColors from 'shared/constants/labelColors';
import Card from './index';
export default {
component: Card,
title: 'Card',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
const labelData = [
{
labelId: 'development',
name: 'Development',
color: LabelColors.BLUE,
active: false,
},
{
labelId: 'general',
name: 'General',
color: LabelColors.PINK,
active: false,
},
];
export const Default = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description=""
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
/>
);
};
export const Labels = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description=""
ref={$ref}
title="Hello, world"
labels={labelData}
onClick={action('on click')}
onContextMenu={action('on context click')}
/>
);
};
export const Badges = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const PastDue = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: true, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const Everything = () => {
const $ref = useRef<HTMLDivElement>(null);
return (
<Card
cardId="1"
listId="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};

View File

@ -0,0 +1,122 @@
import styled, { css } from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles';
export const ClockIcon = styled(FontAwesomeIcon)``;
export const ListCardBadges = styled.div`
float: left;
display: flex;
max-width: 100%;
margin-left: -2px;
`;
export const ListCardBadge = styled.div`
color: #5e6c84;
display: flex;
align-items: center;
margin: 0 6px 4px 0;
max-width: 100%;
min-height: 20px;
overflow: hidden;
position: relative;
padding: 2px;
text-decoration: none;
text-overflow: ellipsis;
vertical-align: top;
`;
export const DescriptionBadge = styled(ListCardBadge)`
padding-right: 6px;
`;
export const DueDateCardBadge = styled(ListCardBadge)<{ isPastDue: boolean }>`
${props =>
props.isPastDue &&
css`
padding-left: 4px;
background-color: #ec9488;
border-radius: 3px;
color: #fff;
`}
`;
export const ListCardBadgeText = styled.span`
font-size: 12px;
padding: 0 4px 0 6px;
vertical-align: top;
white-space: nowrap;
`;
export const ListCardContainer = styled.div<{ isActive: boolean }>`
max-width: 256px;
margin-bottom: 8px;
background-color: #fff;
border-radius: 3px;
${mixin.boxShadowCard}
cursor: pointer !important;
position: relative;
background-color: ${props => (props.isActive ? mixin.darken('#262c49', 0.1) : mixin.lighten('#262c49', 0.05))};
`;
export const ListCardInnerContainer = styled.div`
width: 100%;
height: 100%;
`;
export const ListCardDetails = styled.div`
overflow: hidden;
padding: 6px 8px 2px;
position: relative;
z-index: 10;
`;
export const ListCardLabels = styled.div`
overflow: auto;
position: relative;
`;
export const ListCardLabel = styled.span`
height: 16px;
line-height: 16px;
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;
position: relative;
background-color: ${props => props.color};
`;
export const ListCardOperation = styled.span`
display: flex;
align-content: center;
justify-content: center;
background-color: ${props => mixin.darken('#262c49', 0.15)};
background-clip: padding-box;
background-origin: padding-box;
border-radius: 3px;
opacity: 0.8;
padding: 6px;
position: absolute;
right: 2px;
top: 2px;
z-index: 10;
`;
export const CardTitle = styled.span`
font-family: 'Droid Sans';
clear: both;
display: block;
margin: 0 0 4px;
overflow: hidden;
text-decoration: none;
word-wrap: break-word;
color: #c2c6dc;
`;

View File

@ -0,0 +1,144 @@
import React, { useState, useRef } from 'react';
import { DraggableProvidedDraggableProps } from 'react-beautiful-dnd';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt, faList } from '@fortawesome/free-solid-svg-icons';
import { faClock, faCheckSquare, faEye } from '@fortawesome/free-regular-svg-icons';
import {
DescriptionBadge,
DueDateCardBadge,
ListCardBadges,
ListCardBadge,
ListCardBadgeText,
ListCardContainer,
ListCardInnerContainer,
ListCardDetails,
ClockIcon,
ListCardLabels,
ListCardLabel,
ListCardOperation,
CardTitle,
} from './Styles';
type DueDate = {
isPastDue: boolean;
formattedDate: string;
};
type Checklist = {
complete: number;
total: number;
};
type Props = {
title: string;
description: string;
cardId: string;
listId: string;
onContextMenu: (e: ContextMenuEvent) => void;
onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
dueDate?: DueDate;
checklists?: Checklist;
watched?: boolean;
labels?: Label[];
wrapperProps?: any;
};
const Card = React.forwardRef(
(
{
wrapperProps,
onContextMenu,
cardId,
listId,
onClick,
labels,
title,
dueDate,
description,
checklists,
watched,
}: Props,
$cardRef: any,
) => {
const [isActive, setActive] = useState(false);
const $innerCardRef: any = useRef(null);
const onOpenComposer = () => {
if (typeof $innerCardRef.current !== 'undefined') {
const pos = $innerCardRef.current.getBoundingClientRect();
onContextMenu({
top: pos.top,
left: pos.left,
listId,
cardId,
});
}
};
const onTaskContext = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpenComposer();
};
const onOperationClick = (e: React.MouseEvent<HTMLOrSVGElement>) => {
e.preventDefault();
e.stopPropagation();
onOpenComposer();
};
return (
<ListCardContainer
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
ref={$cardRef}
onClick={onClick}
onContextMenu={onTaskContext}
isActive={isActive}
{...wrapperProps}
>
<ListCardInnerContainer ref={$innerCardRef}>
<ListCardOperation>
<FontAwesomeIcon onClick={onOperationClick} color="#c2c6dc" size="xs" icon={faPencilAlt} />
</ListCardOperation>
<ListCardDetails>
<ListCardLabels>
{labels &&
labels.map(label => (
<ListCardLabel color={label.color} key={label.name}>
{label.name}
</ListCardLabel>
))}
</ListCardLabels>
<CardTitle>{title}</CardTitle>
<ListCardBadges>
{watched && (
<ListCardBadge>
<FontAwesomeIcon color="#6b778c" icon={faEye} size="xs" />
</ListCardBadge>
)}
{dueDate && (
<DueDateCardBadge isPastDue={dueDate.isPastDue}>
<ClockIcon color={dueDate.isPastDue ? '#fff' : '#6b778c'} icon={faClock} size="xs" />
<ListCardBadgeText>{dueDate.formattedDate}</ListCardBadgeText>
</DueDateCardBadge>
)}
{description && (
<DescriptionBadge>
<FontAwesomeIcon color="#6b778c" icon={faList} size="xs" />
</DescriptionBadge>
)}
{checklists && (
<ListCardBadge>
<FontAwesomeIcon color="#6b778c" icon={faCheckSquare} size="xs" />
<ListCardBadgeText>{`${checklists.complete}/${checklists.total}`}</ListCardBadgeText>
</ListCardBadge>
)}
</ListCardBadges>
</ListCardDetails>
</ListCardInnerContainer>
</ListCardContainer>
);
},
);
Card.displayName = 'Card';
export default Card;

View File

@ -0,0 +1,18 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import CardComposer from './index';
export default {
component: CardComposer,
title: 'CardComposer',
parameters: {
backgrounds: [
{ name: 'gray', value: '#f8f8f8', default: true },
{ name: 'white', value: '#ffffff' },
],
},
};
export const Default = () => {
return <CardComposer isOpen onClose={action('on close')} onCreateCard={action('on create card')} />;
};

View File

@ -0,0 +1,89 @@
import styled from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { mixin } from 'shared/utils/styles';
export const CancelIcon = styled(FontAwesomeIcon)`
opacity: 0.8;
cursor: pointer;
font-size: 1.25em;
padding-left: 5px;
`;
export const CardComposerWrapper = styled.div<{ isOpen: boolean }>`
padding-bottom: 8px;
display: ${props => (props.isOpen ? 'flex' : 'none')};
flex-direction: column;
`;
export const ListCard = styled.div`
background-color: #fff;
border-radius: 3px;
${mixin.boxShadowCard}
cursor: pointer;
display: block;
margin-bottom: 8px;
max-width: 300px;
min-height: 20px;
position: relative;
text-decoration: none;
z-index: 0;
`;
export const ListCardDetails = styled.div`
overflow: hidden;
padding: 6px 8px 2px;
position: relative;
z-index: 10;
`;
export const ListCardLabels = styled.div``;
export const ListCardEditor = styled(TextareaAutosize)`
font-family: 'Droid Sans';
overflow: hidden;
overflow-wrap: break-word;
resize: none;
height: 54px;
width: 100%;
background: none;
border: none;
box-shadow: none;
margin-bottom: 4px;
max-height: 162px;
min-height: 54px;
padding: 0;
font-size: 14px;
line-height: 20px;
&:focus {
border: none;
outline: none;
}
`;
export const ComposerControls = styled.div``;
export const ComposerControlsSaveSection = styled.div`
display: flex;
float: left;
align-items: center;
justify-content: center;
`;
export const ComposerControlsActionsSection = styled.div`
float: right;
`;
export const AddCardButton = styled.button`
background-color: #5aac44;
box-shadow: none;
border: none;
color: #fff;
cursor: pointer;
display: inline-block;
font-weight: 400;
line-height: 20px;
margin-right: 4px;
padding: 6px 12px;
text-align: center;
border-radius: 3px;
`;

View File

@ -0,0 +1,85 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import TextareaAutosize from 'react-autosize-textarea';
import {
CardComposerWrapper,
CancelIcon,
AddCardButton,
ListCard,
ListCardDetails,
ListCardEditor,
ComposerControls,
ComposerControlsSaveSection,
ComposerControlsActionsSection,
} from './Styles';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
type Props = {
isOpen: boolean;
onCreateCard: (cardName: string) => void;
onClose: () => void;
};
const CardComposer = ({ isOpen, onCreateCard, onClose }: Props) => {
const [cardName, setCardName] = useState('');
const $cardEditor: any = useRef(null);
const onClick = () => {
onCreateCard(cardName);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
onCreateCard(cardName);
}
};
const onBlur = () => {
if (cardName === '') {
onClose();
} else {
onCreateCard(cardName);
}
};
useOnEscapeKeyDown(isOpen, onClose);
useOnOutsideClick($cardEditor, true, () => onClose(), null);
useEffect(() => {
$cardEditor.current.focus();
}, []);
return (
<CardComposerWrapper isOpen={isOpen}>
<ListCard>
<ListCardDetails>
<ListCardEditor
onKeyDown={onKeyDown}
ref={$cardEditor}
onChange={e => {
setCardName(e.currentTarget.value);
}}
value={cardName}
placeholder="Enter a title for this card..."
/>
</ListCardDetails>
</ListCard>
<ComposerControls>
<ComposerControlsSaveSection>
<AddCardButton onClick={onClick}>Add Card</AddCardButton>
<CancelIcon onClick={onClose} icon={faTimes} color="#42526e" />
</ComposerControlsSaveSection>
<ComposerControlsActionsSection />
</ComposerControls>
</CardComposerWrapper>
);
};
CardComposer.propTypes = {
isOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onCreateCard: PropTypes.func.isRequired,
};
CardComposer.defaultProps = {
isOpen: true,
};
export default CardComposer;

View File

@ -0,0 +1,56 @@
import React, { createRef, useState } from 'react';
import styled from 'styled-components';
import { action } from '@storybook/addon-actions';
import DropdownMenu from './index';
export default {
component: DropdownMenu,
title: 'DropdownMenu',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#f8f8f8' },
{ name: 'darkBlue', value: '#262c49', default: true },
],
},
};
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const Button = styled.div`
font-size: 18px;
padding: 15px 20px;
color: #fff;
background: #000;
`;
export const Default = () => {
const [menu, setMenu] = useState({
top: 0,
left: 0,
isOpen: false,
});
const $buttonRef: any = createRef();
const onClick = () => {
console.log($buttonRef.current.getBoundingClientRect());
setMenu({
isOpen: !menu.isOpen,
left: $buttonRef.current.getBoundingClientRect().right,
top: $buttonRef.current.getBoundingClientRect().bottom,
});
};
return (
<>
<Container>
<Button onClick={onClick} ref={$buttonRef}>
Click me
</Button>
</Container>
{menu.isOpen && <DropdownMenu left={menu.left} top={menu.top} />}
</>
);
};

View File

@ -0,0 +1,74 @@
import styled from 'styled-components/macro';
export const Container = styled.div<{ left: number; top: number }>`
position: absolute;
left: ${props => props.left}px;
top: ${props => props.top}px;
padding-top: 10px;
position: absolute;
height: auto;
width: auto;
transform: translate(-100%);
transition: opacity 0.25s, transform 0.25s, width 0.3s ease;
z-index: 40000;
`;
export const Wrapper = styled.div`
padding: 5px;
padding-top: 8px;
border-radius: 5px;
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.1);
position: relative;
margin: 0;
color: #c2c6dc;
background: #262c49;
border-color: #414561;
`;
export const WrapperDiamond = styled.div`
top: 10px;
right: 10px;
position: absolute;
width: 10px;
height: 10px;
display: block;
transform: rotate(45deg) translate(-7px);
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-left: 1px solid rgba(0, 0, 0, 0.1);
z-index: 10;
background: #262c49;
border-color: #414561;
`;
export const ActionsList = styled.ul`
min-width: 9rem;
margin: 0;
padding: 0;
`;
export const ActionItem = styled.li`
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: rgb(115, 103, 240);
}
`;
export const ActionTitle = styled.span`
margin-left: 0.5rem;
`;
export const Separator = styled.div`
height: 1px;
border-top: 1px solid #414561;
margin: 0.25rem !important;
`;

View File

@ -0,0 +1,32 @@
import React from 'react';
import { Exit, User } from 'shared/icons';
import { Separator, Container, WrapperDiamond, Wrapper, ActionsList, ActionItem, ActionTitle } from './Styles';
type DropdownMenuProps = {
left: number;
top: number;
};
const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top }) => {
return (
<Container left={left} top={top}>
<Wrapper>
<ActionItem>
<User size={16} color="#c2c6dc" />
<ActionTitle>Profile</ActionTitle>
</ActionItem>
<Separator />
<ActionsList>
<ActionItem>
<Exit size={16} color="#c2c6dc" />
<ActionTitle>Logout</ActionTitle>
</ActionItem>
</ActionsList>
</Wrapper>
<WrapperDiamond />
</Container>
);
};
export default DropdownMenu;

View File

@ -0,0 +1,178 @@
import React, { createRef } from 'react';
import { action } from '@storybook/addon-actions';
import Card from 'shared/components/Card';
import CardComposer from 'shared/components/CardComposer';
import LabelColors from 'shared/constants/labelColors';
import List, { ListCards } from './index';
export default {
component: List,
title: 'List',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const labelData = [
{
labelId: 'development',
name: 'Development',
color: LabelColors.BLUE,
active: false,
},
{
labelId: 'general',
name: 'General',
color: LabelColors.PINK,
active: false,
},
];
const createCard = () => {
const $ref = createRef<HTMLDivElement>();
return (
<Card
cardId="1"
listId="1"
description="hello!"
ref={$ref}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
);
};
export const Default = () => {
return (
<List
id=""
name="General"
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen={false}
/>
</ListCards>
</List>
);
};
export const WithCardComposer = () => {
return (
<List
id="1"
name="General"
isComposerOpen
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen
/>
</ListCards>
</List>
);
};
export const WithCard = () => {
const $cardRef: any = createRef();
return (
<List
id="1"
name="General"
isComposerOpen={false}
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<Card
cardId="1"
listId="1"
description="hello!"
ref={$cardRef}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen={false}
/>
</ListCards>
</List>
);
};
export const WithCardAndComposer = () => {
const $cardRef: any = createRef();
return (
<List
id="1"
name="General"
isComposerOpen
onSaveName={action('on save name')}
onOpenComposer={action('on open composer')}
tasks={[]}
>
<ListCards>
<Card
cardId="1"
listId="1"
description="hello!"
ref={$cardRef}
title="Hello, world"
onClick={action('on click')}
onContextMenu={action('on context click')}
watched
labels={labelData}
checklists={{ complete: 1, total: 4 }}
dueDate={{ isPastDue: false, formattedDate: 'Oct 26, 2020' }}
/>
<CardComposer
onClose={() => {
console.log('close!');
}}
onCreateCard={name => {
console.log(name);
}}
isOpen
/>
</ListCards>
</List>
);
};

View File

@ -0,0 +1,119 @@
import styled, { css } from 'styled-components';
import TextareaAutosize from 'react-autosize-textarea';
import { mixin } from 'shared/utils/styles';
export const Container = styled.div`
width: 272px;
margin: 0 4px;
height: 100%;
box-sizing: border-box;
display: inline-block;
vertical-align: top;
white-space: nowrap;
`;
export const AddCardContainer = styled.div`
min-height: 38px;
max-height: 38px;
display: ${props => (props.hidden ? 'none' : 'flex')};
justify-content: space-between;
`;
export const AddCardButton = styled.a`
border-radius: 3px;
color: #5e6c84;
display: flex;
align-items: center;
cursor: pointer;
flex: 1 0 auto;
margin: 2px 8px 8px 8px;
padding: 4px 8px;
position: relative;
text-decoration: none;
user-select: none;
&:hover {
background-color: rgba(9, 30, 66, 0.08);
color: #172b4d;
text-decoration: none;
}
`;
export const Wrapper = styled.div`
// background-color: #ebecf0;
// background: rgb(244, 245, 247);
background: #10163a;
color: #c2c6dc;
border-radius: 5px;
box-sizing: border-box;
display: flex;
flex-direction: column;
max-height: 100%;
position: relative;
white-space: normal;
`;
export const HeaderEditTarget = styled.div<{ isHidden: boolean }>`
cursor: pointer;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: ${props => (props.isHidden ? 'none' : 'block')};
`;
export const HeaderName = styled(TextareaAutosize)`
font-family: 'Droid Sans';
border: none;
resize: none;
overflow: hidden;
overflow-wrap: break-word;
background: transparent;
border-radius: 3px;
box-shadow: none;
font-weight: 600;
margin: -4px 0;
padding: 4px 8px;
letter-spacing: normal;
word-spacing: normal;
text-transform: none;
text-indent: 0px;
text-shadow: none;
flex-direction: column;
text-align: start;
color: #c2c6dc;
`;
export const Header = styled.div<{ isEditing: boolean }>`
flex: 0 0 auto;
padding: 10px 8px;
position: relative;
min-height: 20px;
padding-right: 36px;
${props =>
props.isEditing &&
css`
& ${HeaderName} {
background: #fff;
border: none;
box-shadow: inset 0 0 0 2px #0079bf;
}
`}
`;
export const AddCardButtonText = styled.span`
padding-left: 5px;
font-family: 'Droid Sans';
`;
export const ListCards = styled.div`
margin: 0 4px;
padding: 0 4px;
flex: 1 1 auto;
min-height: 30px;
overflow-y: auto;
overflow-x: hidden;
`;

View File

@ -0,0 +1,101 @@
import React, { useState, useRef } from 'react';
import useOnEscapeKeyDown from 'shared/hooks/onEscapeKeyDown';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
import {
Container,
Wrapper,
Header,
HeaderName,
HeaderEditTarget,
AddCardContainer,
AddCardButton,
AddCardButtonText,
ListCards,
} from './Styles';
type Props = {
children: React.ReactNode;
id: string;
name: string;
onSaveName: (name: string) => void;
isComposerOpen: boolean;
onOpenComposer: (id: string) => void;
tasks: Task[];
wrapperProps?: any;
headerProps?: any;
index?: number;
};
const List = React.forwardRef(
(
{ id, name, onSaveName, isComposerOpen, onOpenComposer, children, wrapperProps, headerProps }: Props,
$wrapperRef: any,
) => {
const [listName, setListName] = useState(name);
const [isEditingTitle, setEditingTitle] = useState(false);
const $listNameRef: any = useRef<HTMLTextAreaElement>();
const onClick = () => {
setEditingTitle(true);
if ($listNameRef) {
$listNameRef.current.select();
}
};
const onBlur = () => {
setEditingTitle(false);
onSaveName(listName);
};
const onEscape = () => {
$listNameRef.current.blur();
};
const onChange = (event: React.FormEvent<HTMLTextAreaElement>): void => {
setListName(event.currentTarget.value);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
$listNameRef.current.blur();
}
};
useOnEscapeKeyDown(isEditingTitle, onEscape);
return (
<Container ref={$wrapperRef} {...wrapperProps}>
<Wrapper>
<Header {...headerProps} isEditing={isEditingTitle}>
<HeaderEditTarget onClick={onClick} isHidden={isEditingTitle} />
<HeaderName
ref={$listNameRef}
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
spellCheck={false}
value={listName}
/>
</Header>
{children && children}
<AddCardContainer hidden={isComposerOpen}>
<AddCardButton onClick={() => onOpenComposer(id)}>
<FontAwesomeIcon icon={faPlus} size="xs" color="#42526e" />
<AddCardButtonText>Add another card</AddCardButtonText>
</AddCardButton>
</AddCardContainer>
</Wrapper>
</Container>
);
},
);
List.defaultProps = {
children: null,
isComposerOpen: false,
wrapperProps: {},
headerProps: {},
};
List.displayName = 'List';
export default List;
export { ListCards };

View File

@ -0,0 +1,184 @@
import React, { useState } from 'react';
import Lists from './index';
import { action } from '@storybook/addon-actions';
export default {
component: Lists,
title: 'Lists',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#f8f8f8' },
],
},
};
const initialListsData = {
columns: {
'column-1': {
taskGroupID: 'column-1',
name: 'General',
taskIds: ['task-3', 'task-4', 'task-1', 'task-2'],
position: 1,
tasks: [],
},
'column-2': {
taskGroupID: 'column-2',
name: 'Development',
taskIds: [],
position: 2,
tasks: [],
},
},
tasks: {
'task-1': {
taskID: 'task-1',
taskGroupID: 'column-1',
name: 'Create roadmap',
position: 2,
labels: [],
},
'task-2': {
taskID: 'task-2',
taskGroupID: 'column-1',
position: 1,
name: 'Create authentication',
labels: [],
},
'task-3': {
taskID: 'task-3',
taskGroupID: 'column-1',
position: 3,
name: 'Create login',
labels: [],
},
'task-4': {
taskID: 'task-4',
taskGroupID: 'column-1',
position: 4,
name: 'Create plugins',
labels: [],
},
},
};
export const Default = () => {
const [listsData, setListsData] = useState(initialListsData);
const onCardDrop = (droppedTask: any) => {
console.log(droppedTask);
const newState = {
...listsData,
tasks: {
...listsData.tasks,
[droppedTask.id]: droppedTask,
},
};
console.log(newState);
setListsData(newState);
};
const onListDrop = (droppedColumn: any) => {
const newState = {
...listsData,
columns: {
...listsData.columns,
[droppedColumn.id]: droppedColumn,
},
};
setListsData(newState);
};
return (
<Lists
{...listsData}
onQuickEditorOpen={action('card composer open')}
onCardDrop={onCardDrop}
onListDrop={onListDrop}
onCardCreate={action('card create')}
/>
);
};
const createColumn = (id: any, name: any, position: any) => {
return {
taskGroupID: id,
name,
position,
tasks: [],
};
};
const initialListsDataLarge = {
columns: {
'column-1': createColumn('column-1', 'General', 1),
'column-2': createColumn('column-2', 'General', 2),
'column-3': createColumn('column-3', 'General', 3),
'column-4': createColumn('column-4', 'General', 4),
'column-5': createColumn('column-5', 'General', 5),
'column-6': createColumn('column-6', 'General', 6),
'column-7': createColumn('column-7', 'General', 7),
'column-8': createColumn('column-8', 'General', 8),
'column-9': createColumn('column-9', 'General', 9),
},
tasks: {
'task-1': {
taskID: 'task-1',
taskGroupID: 'column-1',
name: 'Create roadmap',
position: 2,
labels: [],
},
'task-2': {
taskID: 'task-2',
taskGroupID: 'column-1',
position: 1,
name: 'Create authentication',
labels: [],
},
'task-3': {
taskID: 'task-3',
taskGroupID: 'column-1',
position: 3,
name: 'Create login',
labels: [],
},
'task-4': {
taskID: 'task-4',
taskGroupID: 'column-1',
position: 4,
name: 'Create plugins',
labels: [],
},
},
};
export const ListsWithManyList = () => {
const [listsData, setListsData] = useState(initialListsDataLarge);
const onCardDrop = (droppedTask: any) => {
const newState = {
...listsData,
tasks: {
...listsData.tasks,
[droppedTask.id]: droppedTask,
},
};
setListsData(newState);
};
const onListDrop = (droppedColumn: any) => {
const newState = {
...listsData,
columns: {
...listsData.columns,
[droppedColumn.id]: droppedColumn,
},
};
setListsData(newState);
};
return (
<Lists
{...listsData}
onQuickEditorOpen={action('card composer open')}
onCardCreate={action('card create')}
onCardDrop={onCardDrop}
onListDrop={onListDrop}
/>
);
};

View File

@ -0,0 +1,11 @@
import styled from 'styled-components';
export const Container = styled.div`
flex-grow: 1;
user-select: none;
white-space: nowrap;
margin-bottom: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
`;

View File

@ -0,0 +1,196 @@
import React, { useState } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { moveItemWithinArray, insertItemIntoArray } from 'shared/utils/arrays';
import List, { ListCards } from 'shared/components/List';
import Card from 'shared/components/Card';
import { Container } from './Styles';
import CardComposer from 'shared/components/CardComposer';
const getNewDraggablePosition = (afterDropDraggables: any, draggableIndex: any) => {
const prevDraggable = afterDropDraggables[draggableIndex - 1];
const nextDraggable = afterDropDraggables[draggableIndex + 1];
if (!prevDraggable && !nextDraggable) {
return 1;
}
if (!prevDraggable) {
return nextDraggable.position - 1;
}
if (!nextDraggable) {
return prevDraggable.position + 1;
}
const newPos = (prevDraggable.position + nextDraggable.position) / 2.0;
return newPos;
};
const getSortedDraggables = (draggables: any) => {
return draggables.sort((a: any, b: any) => a.position - b.position);
};
const isPositionChanged = (source: any, destination: any) => {
if (!destination) return false;
const isSameList = destination.droppableId === source.droppableId;
const isSamePosition = destination.index === source.index;
return !isSameList || !isSamePosition;
};
const getAfterDropDraggableList = (
beforeDropDraggables: any,
droppedDraggable: any,
isList: any,
isSameList: any,
destination: any,
) => {
if (isList) {
return moveItemWithinArray(beforeDropDraggables, droppedDraggable, destination.index);
}
return isSameList
? moveItemWithinArray(beforeDropDraggables, droppedDraggable, destination.index)
: insertItemIntoArray(beforeDropDraggables, droppedDraggable, destination.index);
};
interface Columns {
[key: string]: TaskGroup;
}
interface Tasks {
[key: string]: RemoteTask;
}
type Props = {
columns: Columns;
tasks: Tasks;
onCardDrop: any;
onListDrop: any;
onCardCreate: (taskGroupID: string, name: string) => void;
onQuickEditorOpen: (e: ContextMenuEvent) => void;
};
type OnDragEndProps = {
draggableId: any;
source: any;
destination: any;
type: any;
};
const Lists = ({ columns, tasks, onCardDrop, onListDrop, onCardCreate, onQuickEditorOpen }: Props) => {
const onDragEnd = ({ draggableId, source, destination, type }: DropResult) => {
if (typeof destination === 'undefined') return;
if (!isPositionChanged(source, destination)) return;
const isList = type === 'column';
const isSameList = destination.droppableId === source.droppableId;
const droppedDraggable = isList ? columns[draggableId] : tasks[draggableId];
const beforeDropDraggables = isList
? getSortedDraggables(Object.values(columns))
: getSortedDraggables(Object.values(tasks).filter((t: any) => t.taskGroupID === destination.droppableId));
const afterDropDraggables = getAfterDropDraggableList(
beforeDropDraggables,
droppedDraggable,
isList,
isSameList,
destination,
);
const newPosition = getNewDraggablePosition(afterDropDraggables, destination.index);
if (isList) {
onListDrop({
...droppedDraggable,
position: newPosition,
});
} else {
const newCard = {
...droppedDraggable,
position: newPosition,
taskGroupID: destination.droppableId,
};
onCardDrop(newCard);
}
};
const orderedColumns = getSortedDraggables(Object.values(columns));
const [currentComposer, setCurrentComposer] = useState('');
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable direction="horizontal" type="column" droppableId="root">
{provided => (
<Container {...provided.droppableProps} ref={provided.innerRef}>
{orderedColumns.map((column: TaskGroup, index: number) => {
const columnCards = getSortedDraggables(
Object.values(tasks).filter((t: any) => t.taskGroupID === column.taskGroupID),
);
return (
<Draggable draggableId={column.taskGroupID} key={column.taskGroupID} index={index}>
{columnDragProvided => (
<List
id={column.taskGroupID}
name={column.name}
key={column.taskGroupID}
onOpenComposer={id => setCurrentComposer(id)}
isComposerOpen={currentComposer === column.taskGroupID}
onSaveName={name => console.log(name)}
index={index}
tasks={columnCards}
ref={columnDragProvided.innerRef}
wrapperProps={columnDragProvided.draggableProps}
headerProps={columnDragProvided.dragHandleProps}
>
<Droppable type="tasks" droppableId={column.taskGroupID}>
{columnDropProvided => (
<ListCards ref={columnDropProvided.innerRef} {...columnDropProvided.droppableProps}>
{columnCards.map((task: RemoteTask, taskIndex: any) => {
return (
<Draggable key={task.taskID} draggableId={task.taskID} index={taskIndex}>
{taskProvided => {
return (
<Card
wrapperProps={{
...taskProvided.draggableProps,
...taskProvided.dragHandleProps,
}}
ref={taskProvided.innerRef}
cardId={task.taskID}
listId={column.taskGroupID}
description=""
title={task.name}
labels={task.labels}
onClick={e => console.log(e)}
onContextMenu={onQuickEditorOpen}
/>
);
}}
</Draggable>
);
})}
{columnDropProvided.placeholder}
{currentComposer === column.taskGroupID && (
<CardComposer
onClose={() => {
setCurrentComposer('');
}}
onCreateCard={name => {
setCurrentComposer('');
onCardCreate(column.taskGroupID, name);
}}
isOpen={true}
/>
)}
</ListCards>
)}
</Droppable>
</List>
)}
</Draggable>
);
})}
{provided.placeholder}
</Container>
)}
</Droppable>
</DragDropContext>
);
};
export default Lists;

View File

@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import styled from 'styled-components';
import Login from './index';
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export default {
component: Login,
title: 'Login',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#cdd3e1', default: true },
],
},
};
const Container = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
`;
const LoginWrapper = styled.div`
width: 60%;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Container>
<LoginWrapper>
<Login onSubmit={action('on submit')} />
</LoginWrapper>
</Container>
</>
);
};
export const WithSubmission = () => {
const onSubmit = async (data: LoginFormData, setComplete: (val: boolean) => void, setError: any) => {
await sleep(2000);
if (data.username !== 'test' || data.password !== 'test') {
setError('username', 'invalid', 'Invalid username');
setError('password', 'invalid', 'Invalid password');
}
setComplete(true);
};
return (
<>
<NormalizeStyles />
<BaseStyles />
<Container>
<LoginWrapper>
<Login onSubmit={onSubmit} />
</LoginWrapper>
</Container>
</>
);
};

View File

@ -0,0 +1,103 @@
import styled from 'styled-components';
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.input`
padding: 0.75rem 2rem;
font-size: 1rem;
border-radius: 6px;
background: rgb(115, 103, 240);
outline: none;
border: none;
cursor: pointer;
color: #fff;
&:disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
`;
export const ActionButtons = styled.div`
margin-top: 17.5px;
display: flex;
justify-content: space-between;
`;
export const RegisterButton = styled.button`
padding: 0.679rem 2rem;
border-radius: 6px;
border: 1px solid rgb(115, 103, 240);
background: transparent;
font-size: 1rem;
color: rgba(115, 103, 240);
cursor: pointer;
`;

View File

@ -0,0 +1,81 @@
import React, { useState } from 'react';
import AccessAccount from 'shared/undraw/AccessAccount';
import { User, Lock } from 'shared/icons';
import { useForm } from 'react-hook-form';
import {
Form,
ActionButtons,
RegisterButton,
LoginButton,
FormError,
FormIcon,
FormLabel,
FormTextInput,
Wrapper,
Column,
LoginFormWrapper,
LoginFormContainer,
Title,
SubTitle,
} from './Styles';
const Login = ({ onSubmit }: LoginProps) => {
const [isComplete, setComplete] = useState(true);
const { register, handleSubmit, errors, setError, formState } = useForm<LoginFormData>();
console.log(formState);
const loginSubmit = (data: LoginFormData) => {
setComplete(false);
onSubmit(data, setComplete, setError);
};
return (
<Wrapper>
<Column>
<AccessAccount width={275} height={250} />
</Column>
<Column>
<LoginFormWrapper>
<LoginFormContainer>
<Title>Login</Title>
<SubTitle>Welcome back, please login into your account.</SubTitle>
<Form onSubmit={handleSubmit(loginSubmit)}>
<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="password">
Password
<FormTextInput
type="text"
id="password"
name="password"
ref={register({ required: 'Password is required' })}
/>
<FormIcon>
<Lock color="#c2c6dc" size={20} />
</FormIcon>
</FormLabel>
{errors.password && <FormError>{errors.password.message}</FormError>}
<ActionButtons>
<RegisterButton>Register</RegisterButton>
<LoginButton type="submit" value="Login" disabled={!isComplete} />
</ActionButtons>
</Form>
</LoginFormContainer>
</LoginFormWrapper>
</Column>
</Wrapper>
);
};
export default Login;

View File

@ -0,0 +1,41 @@
import React from 'react';
import styled from 'styled-components';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import { Home, Stack, Users, Question } from 'shared/icons';
import Navbar, { ActionButton, ButtonContainer, PrimaryLogo } from './index';
export default {
component: Navbar,
title: 'Navbar',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff', default: true },
{ name: 'gray', value: '#cdd3e1' },
],
},
};
const MainContent = styled.div`
padding: 0 0 50px 80px;
`;
export const Default = () => {
return (
<>
<NormalizeStyles />
<BaseStyles />
<Navbar>
<PrimaryLogo />
<ButtonContainer>
<ActionButton name="Home">
<Home size={28} color="#c2c6dc" />
</ActionButton>
<ActionButton name="Home">
<Home size={28} color="#c2c6dc" />
</ActionButton>
</ButtonContainer>
</Navbar>
</>
);
};

View File

@ -0,0 +1,105 @@
import styled, { css } from 'styled-components';
export const LogoWrapper = styled.div`
margin: 20px 0px 20px;
position: relative;
width: 100%;
height: 42px;
line-height: 42px;
padding-left: 64px;
color: rgb(222, 235, 255);
cursor: pointer;
user-select: none;
transition: color 0.1s ease 0s;
`;
export const Logo = styled.div`
position: absolute;
left: 19px;
`;
export const LogoTitle = styled.div`
position: relative;
right: 12px;
visibility: hidden;
opacity: 0;
font-size: 24px;
font-weight: 600;
transition: right 0.1s ease 0s, visibility, opacity, transform 0.25s ease;
color: #7367f0;
`;
export const ActionContainer = styled.div`
position: relative;
`;
export const ActionButtonTitle = styled.span`
position: relative;
visibility: hidden;
left: -5px;
opacity: 0;
font-weight: 600;
transition: left 0.1s ease 0s, visibility, opacity, transform 0.25s ease;
font-size: 18px;
color: #c2c6dc;
`;
export const IconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s ease;
`;
export const ActionButtonContainer = styled.div`
padding: 0 12px;
position: relative;
`;
export const ActionButtonWrapper = styled.div<{ active?: boolean }>`
${props =>
props.active &&
css`
background: rgb(115, 103, 240);
box-shadow: 0 0 10px 1px rgba(115, 103, 240, 0.7);
`}
border-radius: 6px;
cursor: pointer;
padding: 10px 15px;
display: flex;
align-items: center;
&:hover ${ActionButtonTitle} {
transform: translateX(5px);
}
&:hover ${IconWrapper} {
transform: translateX(5px);
}
`;
export const Container = styled.aside`
z-index: 100;
position: fixed;
top: 0px;
left: 0px;
overflow-x: hidden;
height: 100vh;
width: 80px;
transform: translateZ(0px);
background: #10163a;
transition: all 0.1s ease 0s;
&:hover {
width: 260px;
box-shadow: rgba(0, 0, 0, 0.6) 0px 0px 50px 0px;
}
&:hover ${LogoTitle} {
right: 0px;
visibility: visible;
opacity: 1;
}
&:hover ${ActionButtonTitle} {
left: 15px;
visibility: visible;
opacity: 1;
}
`;

View File

@ -0,0 +1,50 @@
import React from 'react';
import { Citadel } from 'shared/icons';
import {
Container,
LogoWrapper,
IconWrapper,
Logo,
LogoTitle,
ActionContainer,
ActionButtonContainer,
ActionButtonWrapper,
ActionButtonTitle,
} from './Styles';
type ActionButtonProps = {
name: string;
active?: boolean;
};
export const ActionButton: React.FC<ActionButtonProps> = ({ name, active, children }) => {
return (
<ActionButtonWrapper active={active ?? false}>
<IconWrapper>{children}</IconWrapper>
<ActionButtonTitle>{name}</ActionButtonTitle>
</ActionButtonWrapper>
);
};
export const ButtonContainer: React.FC = ({ children }) => (
<ActionContainer>
<ActionButtonContainer>{children}</ActionButtonContainer>
</ActionContainer>
);
export const PrimaryLogo = () => {
return (
<LogoWrapper>
<Logo>
<Citadel size={42} />
</Logo>
<LogoTitle>Citadel</LogoTitle>
</LogoWrapper>
);
};
const Navbar: React.FC = ({ children }) => {
return <Container>{children}</Container>;
};
export default Navbar;

View File

@ -0,0 +1,31 @@
import React, { useState } from 'react';
import LabelColors from 'shared/constants/labelColors';
import { Checkmark } from 'shared/icons';
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
type Props = {
label: Label;
onLabelEdit: (labelId: string, labelName: string, color: string) => void;
};
const LabelManager = ({ label, onLabelEdit }: Props) => {
const [currentLabel, setCurrentLabel] = useState('');
return (
<EditLabelForm>
<FieldLabel>Name</FieldLabel>
<FieldName id="labelName" type="text" name="name" value={currentLabel} />
<FieldLabel>Select a color</FieldLabel>
<div>
{Object.values(LabelColors).map(labelColor => (
<LabelBox color={labelColor}>
<Checkmark color="#fff" size={12} />
</LabelBox>
))}
</div>
<div>
<SaveButton type="submit" value="Save" />
<DeleteButton type="submit" value="Delete" />
</div>
</EditLabelForm>
);
};
export default LabelManager;

Some files were not shown because too many files have changed in this diff Show More