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 @@ - - - - btn_google_dark_disabled_ios - Created with Sketch. - - - - - - - - - - - - - - - - - - - \ 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 @@ - - - - btn_google_dark_focus_ios - Created with Sketch. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 @@ - - - - btn_google_dark_normal_ios - Created with Sketch. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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 @@ - - - - btn_google_dark_pressed_ios - Created with Sketch. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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(