Add twitch authentication and viewing decks.

This commit is contained in:
Gareth Latty
2020-05-23 02:18:51 +01:00
parent 373312ccba
commit 96e6f2d9a0
29 changed files with 650 additions and 253 deletions
-24
View File
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="46px" height="46px" viewBox="0 0 46 46" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.3 (12081) - http://www.bohemiancoding.com/sketch -->
<title>btn_google_dark_disabled_ios</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="0" width="40" height="40" rx="2"></rect>
</defs>
<g id="Google-Button" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="9-PATCH" sketch:type="MSArtboardGroup" transform="translate(-788.000000, -219.000000)"></g>
<g id="btn_google_dark_disabled" sketch:type="MSArtboardGroup" transform="translate(-1.000000, -1.000000)">
<g id="button" sketch:type="MSLayerGroup" transform="translate(4.000000, 4.000000)">
<g id="button-bg">
<use fill-opacity="0.08" fill="#000000" fill-rule="evenodd" sketch:type="MSShapeGroup" xlink:href="#path-1"></use>
<use fill="none" xlink:href="#path-1"></use>
<use fill="none" xlink:href="#path-1"></use>
<use fill="none" xlink:href="#path-1"></use>
</g>
</g>
<path d="M24.001,25.71 L24.001,22.362 L32.425,22.362 C32.551,22.929 32.65,23.46 32.65,24.207 C32.65,29.346 29.203,33 24.01,33 C19.042,33 15.01,28.968 15.01,24 C15.01,19.032 19.042,15 24.01,15 C26.44,15 28.474,15.891 30.031,17.349 L27.475,19.833 C26.827,19.221 25.693,18.501 24.01,18.501 C21.031,18.501 18.601,20.976 18.601,24.009 C18.601,27.042 21.031,29.517 24.01,29.517 C27.457,29.517 28.726,27.132 28.96,25.719 L24.001,25.719 L24.001,25.71 Z" id="Shape-Copy" fill-opacity="0.4" fill="#000000" sketch:type="MSShapeGroup"></path>
<g id="handles_square" sketch:type="MSLayerGroup"></g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-51
View File
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="46px" height="46px" viewBox="0 0 46 46" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.3 (12081) - http://www.bohemiancoding.com/sketch -->
<title>btn_google_dark_focus_ios</title>
<desc>Created with Sketch.</desc>
<defs>
<filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-1">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.168 0" in="shadowBlurOuter1" type="matrix" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.084 0" in="shadowBlurOuter2" type="matrix" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="40" height="40" rx="2"></rect>
<rect id="path-3" x="5" y="5" width="38" height="38" rx="1"></rect>
</defs>
<g id="Google-Button" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="9-PATCH" sketch:type="MSArtboardGroup" transform="translate(-668.000000, -219.000000)"></g>
<g id="btn_google_dark_focus" sketch:type="MSArtboardGroup" transform="translate(-1.000000, -1.000000)">
<rect id="Rectangle-14" fill-opacity="0.3" fill="#4285F4" sketch:type="MSShapeGroup" x="1" y="1" width="46" height="46"></rect>
<g id="button" sketch:type="MSLayerGroup" transform="translate(4.000000, 4.000000)" filter="url(#filter-1)">
<g id="button-bg">
<use fill="#4285F4" fill-rule="evenodd" sketch:type="MSShapeGroup" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
</g>
</g>
<g id="button-bg-copy">
<use fill="#FFFFFF" fill-rule="evenodd" sketch:type="MSShapeGroup" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
</g>
<g id="logo_googleg_48dp" sketch:type="MSLayerGroup" transform="translate(15.000000, 15.000000)">
<path d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z" id="Shape" fill="#4285F4" sketch:type="MSShapeGroup"></path>
<path d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z" id="Shape" fill="#34A853" sketch:type="MSShapeGroup"></path>
<path d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z" id="Shape" fill="#FBBC05" sketch:type="MSShapeGroup"></path>
<path d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z" id="Shape" fill="#EA4335" sketch:type="MSShapeGroup"></path>
<path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape" sketch:type="MSShapeGroup"></path>
</g>
<g id="handles_square" sketch:type="MSLayerGroup"></g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

-50
View File
@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="46px" height="46px" viewBox="0 0 46 46" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.3 (12081) - http://www.bohemiancoding.com/sketch -->
<title>btn_google_dark_normal_ios</title>
<desc>Created with Sketch.</desc>
<defs>
<filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-1">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.168 0" in="shadowBlurOuter1" type="matrix" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.084 0" in="shadowBlurOuter2" type="matrix" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="40" height="40" rx="2"></rect>
<rect id="path-3" x="5" y="5" width="38" height="38" rx="1"></rect>
</defs>
<g id="Google-Button" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="9-PATCH" sketch:type="MSArtboardGroup" transform="translate(-608.000000, -219.000000)"></g>
<g id="btn_google_dark_normal" sketch:type="MSArtboardGroup" transform="translate(-1.000000, -1.000000)">
<g id="button" sketch:type="MSLayerGroup" transform="translate(4.000000, 4.000000)" filter="url(#filter-1)">
<g id="button-bg">
<use fill="#4285F4" fill-rule="evenodd" sketch:type="MSShapeGroup" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
</g>
</g>
<g id="button-bg-copy">
<use fill="#FFFFFF" fill-rule="evenodd" sketch:type="MSShapeGroup" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
</g>
<g id="logo_googleg_48dp" sketch:type="MSLayerGroup" transform="translate(15.000000, 15.000000)">
<path d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z" id="Shape" fill="#4285F4" sketch:type="MSShapeGroup"></path>
<path d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z" id="Shape" fill="#34A853" sketch:type="MSShapeGroup"></path>
<path d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z" id="Shape" fill="#FBBC05" sketch:type="MSShapeGroup"></path>
<path d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z" id="Shape" fill="#EA4335" sketch:type="MSShapeGroup"></path>
<path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape" sketch:type="MSShapeGroup"></path>
</g>
<g id="handles_square" sketch:type="MSLayerGroup"></g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

-50
View File
@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="46px" height="46px" viewBox="0 0 46 46" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<!-- Generator: Sketch 3.3.3 (12081) - http://www.bohemiancoding.com/sketch -->
<title>btn_google_dark_pressed_ios</title>
<desc>Created with Sketch.</desc>
<defs>
<filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-1">
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.168 0" in="shadowBlurOuter1" type="matrix" result="shadowMatrixOuter1"></feColorMatrix>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
<feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.084 0" in="shadowBlurOuter2" type="matrix" result="shadowMatrixOuter2"></feColorMatrix>
<feMerge>
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
<feMergeNode in="shadowMatrixOuter2"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="40" height="40" rx="2"></rect>
<rect id="path-3" x="5" y="5" width="38" height="38" rx="1"></rect>
</defs>
<g id="Google-Button" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="9-PATCH" sketch:type="MSArtboardGroup" transform="translate(-728.000000, -219.000000)"></g>
<g id="btn_google_dark_pressed" sketch:type="MSArtboardGroup" transform="translate(-1.000000, -1.000000)">
<g id="button" sketch:type="MSLayerGroup" transform="translate(4.000000, 4.000000)" filter="url(#filter-1)">
<g id="button-bg">
<use fill="#3367D6" fill-rule="evenodd" sketch:type="MSShapeGroup" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
<use fill="none" xlink:href="#path-2"></use>
</g>
</g>
<g id="handles_square" sketch:type="MSLayerGroup"></g>
<g id="button-bg-copy">
<use fill="#EEEEEE" fill-rule="evenodd" sketch:type="MSShapeGroup" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
<use fill="none" xlink:href="#path-3"></use>
</g>
<g id="logo_googleg_48dp" sketch:type="MSLayerGroup" transform="translate(15.000000, 15.000000)">
<path d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z" id="Shape" fill="#4285F4" sketch:type="MSShapeGroup"></path>
<path d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z" id="Shape" fill="#34A853" sketch:type="MSShapeGroup"></path>
<path d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z" id="Shape" fill="#FBBC05" sketch:type="MSShapeGroup"></path>
<path d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z" id="Shape" fill="#EA4335" sketch:type="MSShapeGroup"></path>
<path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape" sketch:type="MSShapeGroup"></path>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

+1
View File
@@ -15,6 +15,7 @@
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/parser": "1.1.0",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm-community/list-extra": "8.2.4",
+27 -5
View File
@@ -51,6 +51,7 @@ init flags url key =
model =
{ navKey = key
, route = Route.fromUrl url
, origin = { url | path = "", query = Nothing, fragment = Nothing } |> Url.toString
, error = Nothing
, auth = flags.auth
, authMethods = Nothing
@@ -93,8 +94,13 @@ subscriptions model =
onUrlRequest : Browser.UrlRequest -> Msg
onUrlRequest _ =
NoOp
onUrlRequest request =
case request of
Browser.Internal url ->
LoadLink url
Browser.External _ ->
NoOp
onUrlChange : Url -> Msg
@@ -111,13 +117,26 @@ update msg model =
ChangePage route ->
( model, Route.redirectTo route model.navKey )
LoadLink url ->
( model, url |> Url.toString |> Navigation.load )
SetError error ->
case error of
Error.Transient Error.AuthFailure ->
( { model | auth = Nothing }, Route.redirectTo Login model.navKey )
( { model | auth = Nothing }
, Cmd.batch
[ Ports.storeAuth Nothing
, Route.redirectTo (Login Nothing) model.navKey
]
)
Error.User Error.NotAuthenticated ->
( { model | auth = Nothing }, Route.redirectTo Login model.navKey )
( { model | auth = Nothing }
, Cmd.batch
[ Ports.storeAuth Nothing
, Route.redirectTo (Login Nothing) model.navKey
]
)
_ ->
( { model | error = Just error }, Cmd.none )
@@ -146,6 +165,9 @@ update msg model =
Nothing ->
( model, Cmd.none )
Copy id ->
( model, Ports.copy id )
NoOp ->
( model, Cmd.none )
@@ -158,7 +180,7 @@ view model =
body =
case model.route of
Login ->
Login _ ->
Login.view model
Profile ->
@@ -4,10 +4,12 @@ import Json.Decode as Json
import Json.Decode.Pipeline as Json
import ManyDecks.Auth.Google as Google
import ManyDecks.Auth.Guest as Guest
import ManyDecks.Auth.Twitch as Twitch
type alias Methods =
{ google : Maybe Google.Method
, twitch : Maybe Twitch.Method
, guest : Maybe Guest.Method
}
@@ -16,4 +18,5 @@ decode : Json.Decoder Methods
decode =
Json.succeed Methods
|> Json.optional "google" (Google.decode |> Json.map Just) Nothing
|> Json.optional "twitch" (Twitch.decode |> Json.map Just) Nothing
|> Json.optional "guest" (Guest.decode |> Json.map Just) Nothing
+75
View File
@@ -0,0 +1,75 @@
module ManyDecks.Auth.Twitch exposing
( Method
, authPayload
, decode
, requestUrl
)
import Json.Decode as Json
import Json.Decode.Pipeline as Json
import Json.Encode
import Url.Builder as Url
type alias Method =
{ id : String }
authPayload : String -> Maybe Json.Value
authPayload fragment =
let
parts =
fragment |> String.split "&" |> List.map (String.split "=")
extractIdToken part =
case part of
name :: value :: [] ->
if name == "id_token" then
Just value
else
Nothing
_ ->
Nothing
encode token =
let
encodedTokens =
[ ( "id", token |> Json.Encode.string ) ]
in
[ ( "twitch", encodedTokens |> Json.Encode.object ) ] |> Json.Encode.object
in
parts |> List.filterMap extractIdToken |> List.head |> Maybe.map encode
decode : Json.Decoder Method
decode =
Json.succeed Method
|> Json.required "id" Json.string
requestUrl : String -> Method -> String
requestUrl origin { id } =
let
clientId =
id |> Url.string "client_id"
claims =
[ ( "id_token", [ ( "preferred_username", Json.Encode.null ) ] |> Json.Encode.object ) ]
|> Json.Encode.object
|> Json.Encode.encode 0
|> Url.string "claims"
redirectUri =
origin |> Url.string "redirect_uri"
responseType =
"id_token" |> Url.string "response_type"
scope =
"openid" |> Url.string "scope"
in
Url.crossOrigin "https://id.twitch.tv"
[ "oauth2", "authorize" ]
[ clientId, redirectUri, responseType, scope, claims ]
+3
View File
@@ -6,6 +6,7 @@ import ManyDecks.Pages.Decks.Edit.Model as Edit exposing (Change)
import ManyDecks.Pages.Decks.Messages as Decks
import ManyDecks.Pages.Login.Messages as Login
import ManyDecks.Pages.Profile.Messages as Profile
import Url exposing (Url)
type Msg
@@ -18,3 +19,5 @@ type Msg
| DecksMsg Decks.Msg
| ProfileMsg Profile.Msg
| EditMsg Edit.Msg
| LoadLink Url
| Copy String
+2 -1
View File
@@ -13,7 +13,7 @@ import ManyDecks.Pages.Decks.Route as Decks
type Route
= Login
= Login (Maybe String)
| Profile
| Decks Decks.Route
| NotFound String
@@ -23,6 +23,7 @@ type alias Model =
{ navKey : Navigation.Key
, route : Route
, error : Maybe Error
, origin : String
-- Login
, auth : Maybe Auth
+30 -5
View File
@@ -16,6 +16,7 @@ import ManyDecks.Pages.Decks.List as List
import ManyDecks.Pages.Decks.Messages exposing (Msg(..))
import ManyDecks.Pages.Decks.Model exposing (CodeAndSummary)
import ManyDecks.Pages.Decks.Route as Route exposing (Route)
import ManyDecks.Pages.Decks.View as View
import ManyDecks.Ports as Ports
import ManyDecks.Route as GlobalRoute
import Task
@@ -43,9 +44,6 @@ update msg model =
in
( model, Api.createDeck token d (\code -> EditDeck code (Just d) |> Global.DecksMsg) )
Copy id ->
( model, Ports.copy id )
EditDeck code maybeDeck ->
case maybeDeck of
Just d ->
@@ -80,7 +78,7 @@ update msg model =
)
Nothing ->
( model, GlobalRoute.redirectTo Route.Login model.navKey )
( model, GlobalRoute.redirectTo (Route.Login Nothing) model.navKey )
DeckDeleted code ->
let
@@ -103,7 +101,7 @@ update msg model =
)
Nothing ->
( model, GlobalRoute.redirectTo Route.Login model.navKey )
( model, GlobalRoute.redirectTo (Route.Login Nothing) model.navKey )
DeckSaved code deck ->
let
@@ -124,6 +122,25 @@ update msg model =
, Cmd.none
)
ViewDeck code maybeDeck ->
case maybeDeck of
Just d ->
let
route =
Route.Decks (Route.View code)
changeRouteIfNeeded =
if model.route == route then
Cmd.none
else
route |> GlobalRoute.toUrl |> Navigation.pushUrl model.navKey
in
( { model | edit = d |> Edit.init |> Just }, changeRouteIfNeeded )
Nothing ->
( model, GlobalRoute.redirectTo (Route.Decks (Route.View code)) model.navKey )
view : Route -> Model -> List (Html Global.Msg)
view route model =
@@ -138,3 +155,11 @@ view route model =
Nothing ->
[ Html.div [] [ Icon.spinner |> Icon.viewStyled [ Icon.spin ] ] ]
Route.View code ->
case model.edit of
Just edit ->
View.view code model.auth edit
Nothing ->
[ Html.div [] [ Icon.spinner |> Icon.viewStyled [ Icon.spin ] ] ]
+13 -3
View File
@@ -9,6 +9,7 @@ module ManyDecks.Pages.Decks.Deck exposing
, summaryOf
, versionedDecoder
, viewCode
, viewCodeMulti
)
import Cards.Deck as Deck exposing (Deck)
@@ -30,14 +31,23 @@ codeDecoder =
viewCode : (String -> msg) -> Code -> Html msg
viewCode copy (Code c) =
viewCode copy c =
viewCodeMulti copy "" c
viewCodeMulti : (String -> msg) -> String -> Code -> Html msg
viewCodeMulti copy suffix (Code c) =
let
id =
c ++ suffix
in
Html.input
[ c |> HtmlA.id
[ id |> HtmlA.id
, HtmlA.type_ "text"
, HtmlA.readonly True
, HtmlA.class "deck-code"
, HtmlA.value c
, c |> copy |> HtmlE.onClick
, id |> copy |> HtmlE.onClick
]
[]
@@ -52,8 +52,8 @@ deck : CodeAndSummary -> ( String, Html Global.Msg )
deck { code, summary } =
( code |> Deck.codeToString
, Html.li [ HtmlA.class "deck" ]
[ Deck.viewCode (Copy >> Global.DecksMsg) code
, Html.div [ HtmlA.class "details", EditDeck code Nothing |> Global.DecksMsg |> HtmlE.onClick ]
[ Deck.viewCode Global.Copy code
, Html.div [ HtmlA.class "details", ViewDeck code Nothing |> Global.DecksMsg |> HtmlE.onClick ]
[ Html.span [ HtmlA.class "name", HtmlA.title summary.name ] [ Html.text summary.name ]
, Html.span [ HtmlA.class "language" ] [ summary.language |> Maybe.withDefault "" |> Html.text ]
]
@@ -13,8 +13,8 @@ type Msg
| UploadedDeck File
| Json5Parse String
| NewDeck Deck
| Copy String
| EditDeck Deck.Code (Maybe Deck)
| ViewDeck Deck.Code (Maybe Deck)
| BackFromEdit
| Save Deck.Code Json.Patch
| DeckSaved Deck.Code Deck.Versioned
@@ -11,6 +11,7 @@ import Url.Parser exposing (..)
type Route
= List
| View Deck.Code
| Edit Deck.Code
@@ -20,15 +21,19 @@ toUrl route =
List ->
Url.absolute [ "decks" ] []
Edit code ->
View code ->
Url.absolute [ "decks", code |> Deck.codeToString ] []
Edit code ->
Url.absolute [ "decks", code |> Deck.codeToString, "edit" ] []
parser : Parser (Route -> c) c
parser =
oneOf
[ top |> map List
, codeParser |> map Edit
, codeParser </> s "edit" |> map Edit
, codeParser |> map View
]
@@ -0,0 +1,85 @@
module ManyDecks.Pages.Decks.View exposing (view)
import Cards.Call as Call
import Cards.Card as Card
import Cards.Response as Response
import FontAwesome.Icon as Icon
import FontAwesome.Regular as RegIcon
import FontAwesome.Solid as Icon
import Html exposing (Html)
import Html.Attributes as HtmlA
import ManyDecks.Auth exposing (Auth)
import ManyDecks.Messages as Global
import ManyDecks.Meta as Meta
import ManyDecks.Pages.Decks.Deck as Deck
import ManyDecks.Pages.Decks.Edit.Model as Edit
import ManyDecks.Pages.Decks.Messages as Decks
import Material.Button as Button
import Material.Card as MaterialCard
view : Deck.Code -> Maybe Auth -> Edit.Model -> List (Html Global.Msg)
view code auth model =
let
viewAuthor author =
Html.span [ HtmlA.class "author" ] [ Html.text "By ", Html.text author ]
editButton _ =
Button.view Button.Raised
Button.Padded
"Edit"
(Icon.pen |> Icon.viewIcon |> Just)
(Decks.EditDeck code (Just model.deck) |> Global.DecksMsg |> Just)
li content =
Html.li [] [ content ]
responses =
model.deck.responses |> List.map (Response.view Card.Immutable Card.Face >> li)
calls =
model.deck.calls |> List.map (Call.view [] Card.Face >> li)
in
[ MaterialCard.view [ HtmlA.class "page view" ]
[ Html.div [ HtmlA.class "header" ]
[ Html.div [ HtmlA.class "about" ]
[ Html.h1 [ HtmlA.class "title" ] [ code |> Deck.viewCode Global.Copy, model.deck.name |> Html.text ]
, Html.div [ HtmlA.class "details" ]
[ model.deck.author |> Maybe.map viewAuthor |> Maybe.withDefault (Html.text "")
, Html.span [ HtmlA.class "counts" ]
[ Html.span [ HtmlA.class "responses" ]
[ Html.a [ HtmlA.href "#responses" ]
[ RegIcon.square |> Icon.viewIcon
, Html.text "×"
, model.deck.responses |> List.length |> String.fromInt |> Html.text
]
]
, Html.span [ HtmlA.class "calls" ]
[ Html.a [ HtmlA.href "#calls" ]
[ Icon.square |> Icon.viewIcon
, Html.text "×"
, model.deck.calls |> List.length |> String.fromInt |> Html.text
]
]
]
]
]
, auth |> Maybe.map editButton |> Maybe.withDefault (Html.text "")
]
, Html.p [ HtmlA.class "massive-decks-ad" ]
[ Html.text "You can play with this deck on "
, Html.a [ Meta.massiveDecksUrl |> HtmlA.href, HtmlA.target "_blank" ] [ Html.text "Massive Decks" ]
, Html.text ", just use the deck code "
, code |> Deck.viewCodeMulti Global.Copy "-2"
, Html.text " after selecting "
, Icon.boxOpen |> Icon.viewIcon
, Html.text " Many Decks, or you can try "
, Html.a [ HtmlA.href "/" ] [ Html.text "making your own decks here" ]
, Html.text "."
]
, Html.div [ HtmlA.class "cards" ]
[ Html.ul [ HtmlA.id "responses" ] responses
, Html.ul [ HtmlA.id "calls" ] calls
]
]
]
+26 -3
View File
@@ -3,6 +3,8 @@ module ManyDecks.Pages.Login exposing
, view
)
import Browser.Navigation as Navigation
import FontAwesome.Brands as Icon
import FontAwesome.Icon as Icon
import FontAwesome.Solid as Icon
import Html exposing (Html)
@@ -11,6 +13,7 @@ import ManyDecks.Api as Api
import ManyDecks.Auth.Google as Google
import ManyDecks.Auth.Guest as Guest
import ManyDecks.Auth.Methods as Auth
import ManyDecks.Auth.Twitch as Twitch
import ManyDecks.Messages as Global
import ManyDecks.Model exposing (Model, Route(..))
import ManyDecks.Pages.Decks.Route as Decks
@@ -36,6 +39,9 @@ update msg model =
TryGuestSignIn _ ->
( model, Api.signIn Guest.authPayload (SetAuth >> Global.LoginMsg) )
TryTwitchSignIn method ->
( model, method |> Twitch.requestUrl model.origin |> Navigation.load )
SetAuth auth ->
( { model | auth = Just auth, usernameField = auth.name }
, Cmd.batch
@@ -45,7 +51,12 @@ update msg model =
)
SignOut ->
( { model | auth = Nothing }, Route.redirectTo Login model.navKey )
( { model | auth = Nothing }
, Cmd.batch
[ Ports.storeAuth Nothing
, Route.redirectTo (Login Nothing) model.navKey
]
)
view : Model -> List (Html Global.Msg)
@@ -68,7 +79,7 @@ view model =
[ Html.text "Currently the data for this service is not backed up! Please keep local copies of your "
, Html.text "decks as well, just in case something goes wrong."
]
, Html.div [] (model.authMethods |> Maybe.map viewMethods |> Maybe.withDefault [])
, Html.div [ HtmlA.class "methods" ] (model.authMethods |> Maybe.map viewMethods |> Maybe.withDefault [])
]
]
@@ -77,6 +88,7 @@ viewMethods : Auth.Methods -> List (Html Global.Msg)
viewMethods methods =
List.filterMap identity
[ methods.google |> Maybe.map google
, methods.twitch |> Maybe.map twitch
, methods.guest |> Maybe.map guest
]
@@ -87,11 +99,22 @@ google method =
[ Button.view Button.Raised
Button.Padded
"Sign in with Google"
(Html.div [ HtmlA.class "google-icon" ] [] |> Just)
(Icon.google |> Icon.viewIcon |> Just)
(method |> TryGoogleSignIn |> Global.LoginMsg |> Just)
]
twitch : Twitch.Method -> Html Global.Msg
twitch method =
Html.div [ HtmlA.id "twitch-sign-in" ]
[ Button.view Button.Raised
Button.Padded
"Sign in with Twitch"
(Icon.twitch |> Icon.viewIcon |> Just)
(method |> TryTwitchSignIn |> Global.LoginMsg |> Just)
]
guest : Guest.Method -> Html Global.Msg
guest method =
Html.div [ HtmlA.id "guest-sign-in" ]
@@ -4,6 +4,7 @@ import ManyDecks.Auth exposing (Auth)
import ManyDecks.Auth.Google as Google
import ManyDecks.Auth.Guest as Guest
import ManyDecks.Auth.Methods as Auth
import ManyDecks.Auth.Twitch as Twitch
type Msg
@@ -11,5 +12,6 @@ type Msg
| TryGoogleSignIn Google.Method
| GoogleAuthResult String
| TryGuestSignIn Guest.Method
| TryTwitchSignIn Twitch.Method
| SetAuth Auth
| SignOut
+1 -1
View File
@@ -50,7 +50,7 @@ update msg model =
( model, Cmd.none )
ProfileDeleted ->
( { model | auth = Nothing }, Route.redirectTo Login model.navKey )
( { model | auth = Nothing }, Route.redirectTo (Login Nothing) model.navKey )
Backup ->
case model.auth of
+76 -10
View File
@@ -6,12 +6,16 @@ module ManyDecks.Route exposing
)
import Browser.Navigation as Navigation
import Cards.Deck exposing (Deck)
import ManyDecks.Api as Api
import ManyDecks.Auth.Twitch as Twitch
import ManyDecks.Messages exposing (Msg(..))
import ManyDecks.Model exposing (..)
import ManyDecks.Pages.Decks.Deck as Deck
import ManyDecks.Pages.Decks.Messages as Decks
import ManyDecks.Pages.Decks.Route as Decks
import ManyDecks.Pages.Login.Messages as Login
import Task
import Url exposing (Url)
import Url.Builder as Url
import Url.Parser exposing (..)
@@ -24,13 +28,29 @@ onRouteChanged route oldModel =
{ oldModel | route = route }
in
case route of
Login ->
case model.auth of
Just _ ->
( model, redirectTo (Decks Decks.List) model.navKey )
Login fragment ->
case fragment of
Just frag ->
let
parsedPayload =
Twitch.authPayload frag
signIn payload =
Api.signIn payload (Login.SetAuth >> LoginMsg)
in
( model
, parsedPayload
|> Maybe.map signIn
|> Maybe.withDefault (Api.getAuthMethods (Login.ReceiveMethods >> LoginMsg))
)
Nothing ->
( model, Api.getAuthMethods (Login.ReceiveMethods >> LoginMsg) )
case model.auth of
Just _ ->
( model, redirectTo (Decks Decks.List) model.navKey )
Nothing ->
( model, Api.getAuthMethods (Login.ReceiveMethods >> LoginMsg) )
Profile ->
case model.auth of
@@ -38,7 +58,7 @@ onRouteChanged route oldModel =
( model, Cmd.none )
Nothing ->
( model, redirectTo Login model.navKey )
( model, redirectTo (Login Nothing) model.navKey )
Decks decksRoute ->
case decksRoute of
@@ -48,15 +68,61 @@ onRouteChanged route oldModel =
( model, Api.getDecks auth.token (Decks.ReceiveDecks >> DecksMsg) )
Nothing ->
( model, redirectTo Login model.navKey )
( model, redirectTo (Login Nothing) model.navKey )
Decks.View code ->
let
getDeck =
getExistingDeck oldModel code
|> Maybe.map fakeApiCall
|> Maybe.withDefault (Api.getDeck code)
in
( model, getDeck (\deck -> Decks.ViewDeck code (Just deck) |> DecksMsg) )
Decks.Edit code ->
( model, Api.getDeck code (\deck -> Decks.EditDeck code (Just deck) |> DecksMsg) )
case model.auth of
Just _ ->
let
getDeck =
getExistingDeck oldModel code
|> Maybe.map fakeApiCall
|> Maybe.withDefault (Api.getDeck code)
in
( model, getDeck (\deck -> Decks.EditDeck code (Just deck) |> DecksMsg) )
Nothing ->
( model, redirectTo (Login Nothing) model.navKey )
NotFound _ ->
( model, Cmd.none )
fakeApiCall : Deck -> (Deck -> Msg) -> Cmd Msg
fakeApiCall deck wrap =
deck |> Task.succeed |> Task.perform wrap
getExistingDeck : Model -> Deck.Code -> Maybe Deck
getExistingDeck oldModel code =
case oldModel.route of
Decks (Decks.View oldCode) ->
if oldCode == code then
oldModel.edit |> Maybe.map .deck
else
Nothing
Decks (Decks.Edit oldCode) ->
if oldCode == code then
oldModel.edit |> Maybe.map .deck
else
Nothing
_ ->
Nothing
redirectTo : Route -> Navigation.Key -> Cmd Msg
redirectTo route navKey =
route |> toUrl |> Navigation.pushUrl navKey
@@ -65,7 +131,7 @@ redirectTo route navKey =
toUrl : Route -> String
toUrl route =
case route of
Login ->
Login _ ->
Url.absolute [] []
Profile ->
@@ -86,7 +152,7 @@ fromUrl url =
parser : Parser (Route -> c) c
parser =
oneOf
[ top |> map Login
[ top </> fragment Login
, s "profile" |> map Profile
, s "decks" </> Decks.parser |> map Decks
]
+16 -32
View File
@@ -56,40 +56,24 @@
-0.05em -0.05em 0.4em transparentize(#000000, 0.7);
}
}
.methods {
display: flex;
flex-direction: column;
align-items: center;
> * {
margin: 0.5em;
}
}
}
#google-sign-in mwc-button {
#google-sign-in {
--mdc-theme-primary: #4285f4;
--mdc-theme-on-primary: #ffffff;
--mdc-button-horizontal-padding: 0.5em;
--mdc-shape-small: 0;
margin: 0.5em;
> span {
padding: 0;
display: flex;
align-items: center;
margin-left: 1em;
}
.google-icon {
background-image: url("../../assets/images/google/normal.svg");
background-size: 100% 100%;
width: 2em;
height: 2em;
margin: 0 0.5em 0 -1em;
position: absolute;
}
&:disabled .google-icon {
background-image: url("../../assets/images/google/disabled.svg");
}
&:focus .google-icon {
background-image: url("../../assets/images/google/focus.svg");
}
&:active .google-icon {
background-image: url("../../assets/images/google/pressed.svg");
}
}
#twitch-sign-in {
--mdc-theme-primary: #772ce8;
--mdc-theme-on-primary: #ffffff;
}
+65
View File
@@ -0,0 +1,65 @@
.view {
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1em;
.deck-code {
margin-right: 0.5em;
}
mwc-button {
align-self: inherit;
}
.details {
display: flex;
flex-wrap: wrap;
.counts {
margin-left: 0.5em;
.responses,
.calls {
margin-right: 0.5em;
a {
color: #000000;
text-decoration: none;
}
}
}
}
}
.deck-code {
font-size: 1em;
width: 5em;
}
.massive-decks-ad {
text-align: center;
}
.cards {
display: flex;
flex-direction: column;
}
#responses,
#calls {
list-style: none;
padding: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
align-content: center;
li {
margin: 0.5em;
}
}
}
+7
View File
@@ -1,6 +1,7 @@
@use "../../elm-material/src/scss/_material";
@use "./_edit";
@use "./_view";
@use "./_profile";
@use "./_decks";
@use "./_login";
@@ -29,6 +30,12 @@ body {
font-family: "Helvetica Neue", "Nimbus Sans L", sans-serif;
}
.page {
display: flex;
flex-direction: column;
margin: 3em;
}
.content {
display: flex;
justify-content: center;
+7
View File
@@ -35,5 +35,12 @@
// google: {
// id: "CHANGE ME", // A client id in the form "YOUR_CLIENT_ID.apps.googleusercontent.com" (include the latter part).
// },
// Allows a user ot sign in via Twitch.
// See https://dev.twitch.tv/docs/authentication#registration to obtain these details.
// twitch: {
// jwk: "https://id.twitch.tv/oauth2/keys",
// id: "CHANGE ME",
// },
},
}
+115 -11
View File
@@ -49,7 +49,6 @@
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz",
"integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==",
"dev": true,
"requires": {
"@types/connect": "*",
"@types/node": "*"
@@ -64,7 +63,6 @@
"version": "3.4.33",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz",
"integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==",
"dev": true,
"requires": {
"@types/node": "*"
}
@@ -79,7 +77,6 @@
"version": "4.17.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz",
"integrity": "sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==",
"dev": true,
"requires": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "*",
@@ -87,17 +84,33 @@
"@types/serve-static": "*"
}
},
"@types/express-jwt": {
"version": "0.0.42",
"resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.42.tgz",
"integrity": "sha512-WszgUddvM1t5dPpJ3LhWNH8kfNN8GPIBrAGxgIYXVCEGx6Bx4A036aAuf/r5WH9DIEdlmp7gHOYvSM6U87B0ag==",
"requires": {
"@types/express": "*",
"@types/express-unless": "*"
}
},
"@types/express-serve-static-core": {
"version": "4.17.7",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.7.tgz",
"integrity": "sha512-EMgTj/DF9qpgLXyc+Btimg+XoH7A2liE8uKul8qSmMTHCeNYzydDKFdsJskDvw42UsesCnhO63dO0Grbj8J4Dw==",
"dev": true,
"requires": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*"
}
},
"@types/express-unless": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.5.1.tgz",
"integrity": "sha512-5fuvg7C69lemNgl0+v+CUxDYWVPSfXHhJPst4yTLcqi4zKJpORCxnDrnnilk3k0DTq/WrAUdvXFs01+vUqUZHw==",
"requires": {
"@types/express": "*"
}
},
"@types/express-winston": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/express-winston/-/express-winston-4.0.0.tgz",
@@ -158,8 +171,7 @@
"@types/mime": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.2.tgz",
"integrity": "sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q==",
"dev": true
"integrity": "sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q=="
},
"@types/node": {
"version": "14.0.5",
@@ -183,20 +195,17 @@
"@types/qs": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.3.tgz",
"integrity": "sha512-7s9EQWupR1fTc2pSMtXRQ9w9gLOcrJn+h7HOXw4evxyvVqMi4f+q7d2tnFe3ng3SNHjtK+0EzGMGFUQX4/AQRA==",
"dev": true
"integrity": "sha512-7s9EQWupR1fTc2pSMtXRQ9w9gLOcrJn+h7HOXw4evxyvVqMi4f+q7d2tnFe3ng3SNHjtK+0EzGMGFUQX4/AQRA=="
},
"@types/range-parser": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
"integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==",
"dev": true
"integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA=="
},
"@types/serve-static": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.4.tgz",
"integrity": "sha512-jTDt0o/YbpNwZbQmE/+2e+lfjJEJJR0I3OFaKQKPWkASkCoW3i6fsUnqudSMcNAfbtmADGu8f4MV4q+GqULmug==",
"dev": true,
"requires": {
"@types/express-serve-static-core": "*",
"@types/mime": "*"
@@ -513,6 +522,14 @@
"lodash": "^4.17.14"
}
},
"axios": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
"integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
"requires": {
"follow-redirects": "1.5.10"
}
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@@ -1555,6 +1572,24 @@
"integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==",
"dev": true
},
"follow-redirects": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"requires": {
"debug": "=3.1.0"
},
"dependencies": {
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
}
}
}
},
"forwarded": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
@@ -2241,6 +2276,35 @@
"safe-buffer": "^5.0.1"
}
},
"jwks-rsa": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-1.8.0.tgz",
"integrity": "sha512-+HYROHD5fsYQCNrJ37RSr2NjbN2/V9YT+yVF3oJxLmPIZWrmp1SOl1hMw2RcuNh+LGA2bGZIhRKGiMjhQa/b7Q==",
"requires": {
"@types/express-jwt": "0.0.42",
"axios": "^0.19.2",
"debug": "^4.1.0",
"jsonwebtoken": "^8.5.1",
"limiter": "^1.1.4",
"lru-memoizer": "^2.0.1",
"ms": "^2.1.2"
},
"dependencies": {
"debug": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
"integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
"requires": {
"ms": "^2.1.1"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}
}
},
"jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
@@ -2294,6 +2358,11 @@
"type-check": "~0.4.0"
}
},
"limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -2307,6 +2376,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
},
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -2375,6 +2449,31 @@
"yallist": "^3.0.2"
}
},
"lru-memoizer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.1.2.tgz",
"integrity": "sha512-N5L5xlnVcbIinNn/TJ17vHBZwBMt9t7aJDz2n97moWubjNl6VO9Ao2XuAGBBddkYdjrwR9HfzXbT6NfMZXAZ/A==",
"requires": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "~4.0.0"
},
"dependencies": {
"lru-cache": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz",
"integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=",
"requires": {
"pseudomap": "^1.0.1",
"yallist": "^2.0.0"
}
},
"yallist": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
}
}
},
"make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -2883,6 +2982,11 @@
"ipaddr.js": "1.9.1"
}
},
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM="
},
"pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+1
View File
@@ -33,6 +33,7 @@
"io-ts": "^2.2.3",
"json5": "^2.1.3",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^1.8.0",
"node-pg-migrate": "^5.0.0",
"pg": "^8.2.1",
"postgres-migrations": "^4.0.3",
+7
View File
@@ -0,0 +1,7 @@
-- Users can now log in with a twitch account.
ALTER TABLE manydecks.users ADD COLUMN twitch_id TEXT,
-- One account per twitch account.
ADD UNIQUE (twitch_id),
-- Replace our check that the user has at least one login method to allow for twitch-only accounts.
DROP CONSTRAINT can_login,
ADD CONSTRAINT can_login CHECK (google_id IS NOT NULL OR twitch_id IS NOT NULL OR is_guest);
+54 -1
View File
@@ -16,6 +16,19 @@ import * as Uuid from "uuid";
// @ts-ignore
import { default as Zip } from "express-easy-zip";
import * as Errors from "./errors";
import { default as Jwks } from "jwks-rsa";
import * as util from "util";
import {
default as Jwt,
GetPublicKeyOrSecret,
VerifyErrors,
} from "jsonwebtoken";
import { AuthFailure } from "./errors";
interface TwitchClaims {
sub: string;
preferred_username?: string;
}
sourceMapSupport.install();
@@ -38,10 +51,35 @@ const main = async (): Promise<void> => {
const auth = new Auth.Auth(config.auth.local);
const google =
config.auth.google == undefined
config.auth.google === undefined
? undefined
: new GoogleAuth.OAuth2Client(config.auth.google.id);
const twitch =
config.auth.twitch === undefined
? undefined
: Jwks({
jwksUri: config.auth.twitch.jwk,
});
const verifyWith = async (
token: string,
twitch: Jwks.JwksClient
): Promise<TwitchClaims> => {
const keyFromHeader = async (header: Jwt.JwtHeader) => {
const getPubKey = util.promisify(twitch.getSigningKey);
const key = await getPubKey(header.kid as string);
return key.getPublicKey();
};
const decoded = Jwt.decode(token, { complete: true });
if (decoded === null || !decoded.hasOwnProperty("header")) {
throw new AuthFailure();
}
// @ts-ignore
const key = await keyFromHeader(decoded.header);
return Jwt.verify(token, key, { algorithms: ["RS256"] }) as TwitchClaims;
};
const app = express();
app.use(helmet());
@@ -60,6 +98,7 @@ const main = async (): Promise<void> => {
res.json({
...(auth.guest !== undefined ? { guest: {} } : {}),
...(auth.google !== undefined ? { google: { id: auth.google.id } } : {}),
...(auth.twitch !== undefined ? { twitch: { id: auth.twitch.id } } : {}),
});
});
@@ -88,7 +127,21 @@ const main = async (): Promise<void> => {
res.json({ token: auth.sign({ sub: id }), name });
return;
}
} else if (req.body.twitch) {
if (twitch === undefined || config.auth.twitch === undefined) {
throw new Errors.AuthFailure();
}
const claims = await verifyWith(req.body.twitch.id, twitch);
const { id, name } = await store.findOrCreateTwitchUser(
claims.sub,
claims.preferred_username
);
res.json({ token: auth.sign({ sub: id }), name });
return;
} else if (req.body.guest) {
if (config.auth.guest === undefined) {
throw new Errors.AuthFailure();
}
const { id, name } = await store.findOrCreateGuestUser();
res.json({ token: auth.sign({ sub: id }), name });
return;
+24 -1
View File
@@ -55,7 +55,7 @@ export class Store {
public findOrCreateUser: (
googleId: string,
googleName: string | undefined
googleName?: string
) => Promise<User.User> = async (googleId, googleName) =>
await this.withClient(async (client) => {
const existingResult = await client.query(
@@ -76,6 +76,29 @@ export class Store {
}
});
public findOrCreateTwitchUser: (
twitchId: string,
twitchName?: string
) => Promise<User.User> = async (twitchId, twitchName) =>
await this.withClient(async (client) => {
const existingResult = await client.query(
`SELECT id, name FROM manydecks.users WHERE twitch_id = $1;`,
[twitchId]
);
if (existingResult.rowCount > 0) {
const [user] = existingResult.rows;
return { id: user.id, name: user.name };
} else {
const newId = User.id();
const newName = twitchName === undefined ? "New User" : twitchName;
await client.query(
`INSERT INTO manydecks.users (id, name, twitch_id) VALUES ($1, $2, $3);`,
[newId, newName, twitchId]
);
return { id: newId, name: newName };
}
});
public findOrCreateGuestUser: () => Promise<User.User> = async () =>
await this.withClient(async (client) => {
const existingResult = await client.query(