diff --git a/client/assets/images/google/disabled.svg b/client/assets/images/google/disabled.svg
deleted file mode 100644
index ecda263..0000000
--- a/client/assets/images/google/disabled.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
\ No newline at end of file
diff --git a/client/assets/images/google/focus.svg b/client/assets/images/google/focus.svg
deleted file mode 100644
index dbf2c96..0000000
--- a/client/assets/images/google/focus.svg
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
\ No newline at end of file
diff --git a/client/assets/images/google/normal.svg b/client/assets/images/google/normal.svg
deleted file mode 100644
index 8464cb2..0000000
--- a/client/assets/images/google/normal.svg
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
\ No newline at end of file
diff --git a/client/assets/images/google/pressed.svg b/client/assets/images/google/pressed.svg
deleted file mode 100644
index 3552b43..0000000
--- a/client/assets/images/google/pressed.svg
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
\ No newline at end of file
diff --git a/client/elm.json b/client/elm.json
index cd12888..af64ac3 100644
--- a/client/elm.json
+++ b/client/elm.json
@@ -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",
diff --git a/client/src/elm/ManyDecks.elm b/client/src/elm/ManyDecks.elm
index e6b3237..fe88c33 100644
--- a/client/src/elm/ManyDecks.elm
+++ b/client/src/elm/ManyDecks.elm
@@ -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 ->
diff --git a/client/src/elm/ManyDecks/Auth/Methods.elm b/client/src/elm/ManyDecks/Auth/Methods.elm
index 3a1cc15..7b78061 100644
--- a/client/src/elm/ManyDecks/Auth/Methods.elm
+++ b/client/src/elm/ManyDecks/Auth/Methods.elm
@@ -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
diff --git a/client/src/elm/ManyDecks/Auth/Twitch.elm b/client/src/elm/ManyDecks/Auth/Twitch.elm
new file mode 100644
index 0000000..3dcd26b
--- /dev/null
+++ b/client/src/elm/ManyDecks/Auth/Twitch.elm
@@ -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 ]
diff --git a/client/src/elm/ManyDecks/Messages.elm b/client/src/elm/ManyDecks/Messages.elm
index 8330a90..ff66997 100644
--- a/client/src/elm/ManyDecks/Messages.elm
+++ b/client/src/elm/ManyDecks/Messages.elm
@@ -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
diff --git a/client/src/elm/ManyDecks/Model.elm b/client/src/elm/ManyDecks/Model.elm
index a53c425..9c5ab2d 100644
--- a/client/src/elm/ManyDecks/Model.elm
+++ b/client/src/elm/ManyDecks/Model.elm
@@ -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
diff --git a/client/src/elm/ManyDecks/Pages/Decks.elm b/client/src/elm/ManyDecks/Pages/Decks.elm
index c9b46e6..a009433 100644
--- a/client/src/elm/ManyDecks/Pages/Decks.elm
+++ b/client/src/elm/ManyDecks/Pages/Decks.elm
@@ -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 ] ] ]
diff --git a/client/src/elm/ManyDecks/Pages/Decks/Deck.elm b/client/src/elm/ManyDecks/Pages/Decks/Deck.elm
index 223ceda..82cd654 100644
--- a/client/src/elm/ManyDecks/Pages/Decks/Deck.elm
+++ b/client/src/elm/ManyDecks/Pages/Decks/Deck.elm
@@ -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
]
[]
diff --git a/client/src/elm/ManyDecks/Pages/Decks/List.elm b/client/src/elm/ManyDecks/Pages/Decks/List.elm
index d453f49..03e9186 100644
--- a/client/src/elm/ManyDecks/Pages/Decks/List.elm
+++ b/client/src/elm/ManyDecks/Pages/Decks/List.elm
@@ -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 ]
]
diff --git a/client/src/elm/ManyDecks/Pages/Decks/Messages.elm b/client/src/elm/ManyDecks/Pages/Decks/Messages.elm
index 55a7135..c7ff4d5 100644
--- a/client/src/elm/ManyDecks/Pages/Decks/Messages.elm
+++ b/client/src/elm/ManyDecks/Pages/Decks/Messages.elm
@@ -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
diff --git a/client/src/elm/ManyDecks/Pages/Decks/Route.elm b/client/src/elm/ManyDecks/Pages/Decks/Route.elm
index 7bd5ffe..3f6d5b6 100644
--- a/client/src/elm/ManyDecks/Pages/Decks/Route.elm
+++ b/client/src/elm/ManyDecks/Pages/Decks/Route.elm
@@ -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
]
diff --git a/client/src/elm/ManyDecks/Pages/Decks/View.elm b/client/src/elm/ManyDecks/Pages/Decks/View.elm
new file mode 100644
index 0000000..5d90368
--- /dev/null
+++ b/client/src/elm/ManyDecks/Pages/Decks/View.elm
@@ -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
+ ]
+ ]
+ ]
diff --git a/client/src/elm/ManyDecks/Pages/Login.elm b/client/src/elm/ManyDecks/Pages/Login.elm
index b08427c..4f09f15 100644
--- a/client/src/elm/ManyDecks/Pages/Login.elm
+++ b/client/src/elm/ManyDecks/Pages/Login.elm
@@ -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" ]
diff --git a/client/src/elm/ManyDecks/Pages/Login/Messages.elm b/client/src/elm/ManyDecks/Pages/Login/Messages.elm
index e29809f..5c6e561 100644
--- a/client/src/elm/ManyDecks/Pages/Login/Messages.elm
+++ b/client/src/elm/ManyDecks/Pages/Login/Messages.elm
@@ -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
diff --git a/client/src/elm/ManyDecks/Pages/Profile.elm b/client/src/elm/ManyDecks/Pages/Profile.elm
index 38d5737..7b7c425 100644
--- a/client/src/elm/ManyDecks/Pages/Profile.elm
+++ b/client/src/elm/ManyDecks/Pages/Profile.elm
@@ -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
diff --git a/client/src/elm/ManyDecks/Route.elm b/client/src/elm/ManyDecks/Route.elm
index 05d04f9..e47aee4 100644
--- a/client/src/elm/ManyDecks/Route.elm
+++ b/client/src/elm/ManyDecks/Route.elm
@@ -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
]
diff --git a/client/src/scss/_login.scss b/client/src/scss/_login.scss
index 1378b89..c8cf94e 100644
--- a/client/src/scss/_login.scss
+++ b/client/src/scss/_login.scss
@@ -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;
}
diff --git a/client/src/scss/_view.scss b/client/src/scss/_view.scss
new file mode 100644
index 0000000..49f5270
--- /dev/null
+++ b/client/src/scss/_view.scss
@@ -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;
+ }
+ }
+}
diff --git a/client/src/scss/many-decks.scss b/client/src/scss/many-decks.scss
index 3242e57..1b06251 100644
--- a/client/src/scss/many-decks.scss
+++ b/client/src/scss/many-decks.scss
@@ -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;
diff --git a/server/config.json5 b/server/config.json5
index 1626f5c..8d3d712 100644
--- a/server/config.json5
+++ b/server/config.json5
@@ -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",
+ // },
},
}
diff --git a/server/package-lock.json b/server/package-lock.json
index 80ba759..0c4fd52 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -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",
diff --git a/server/package.json b/server/package.json
index 90ad1aa..da6369a 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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",
diff --git a/server/src/sql/3_twitch_auth.sql b/server/src/sql/3_twitch_auth.sql
new file mode 100644
index 0000000..30c3cfa
--- /dev/null
+++ b/server/src/sql/3_twitch_auth.sql
@@ -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);
diff --git a/server/src/ts/index.ts b/server/src/ts/index.ts
index b33e70f..29d592f 100644
--- a/server/src/ts/index.ts
+++ b/server/src/ts/index.ts
@@ -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 => {
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 => {
+ 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 => {
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 => {
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;
diff --git a/server/src/ts/store.ts b/server/src/ts/store.ts
index 43e1aec..62b799b 100644
--- a/server/src/ts/store.ts
+++ b/server/src/ts/store.ts
@@ -55,7 +55,7 @@ export class Store {
public findOrCreateUser: (
googleId: string,
- googleName: string | undefined
+ googleName?: string
) => Promise = 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 = 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 = async () =>
await this.withClient(async (client) => {
const existingResult = await client.query(