From c6d55561bf8422301ea7a2d08b9a7c3e30a1440e Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Sat, 30 Oct 2021 19:24:00 +0300 Subject: Implement authentication --- db.json | 109 ++++++++++++++++------------------------------------- main.js | 46 ++++++++++++---------- src/Config.elm | 6 ++- src/Header.elm | 34 +++++++++++++++++ src/Main.elm | 80 ++++++++++++++------------------------- src/Pages/Feed.elm | 40 ++++++++++++++------ src/RemoteData.elm | 52 +++++++++++++++++++++++++ src/Types.elm | 16 +++++++- src/Utils.elm | 42 +++++++++++++++++++++ 9 files changed, 263 insertions(+), 162 deletions(-) create mode 100644 src/Header.elm create mode 100644 src/RemoteData.elm create mode 100644 src/Utils.elm diff --git a/db.json b/db.json index 7a81353..0f6afd0 100644 --- a/db.json +++ b/db.json @@ -1,104 +1,59 @@ { "posts": [ { - "id": "5CEDAFAA-4CB9-4BF9-A0B2-EB4DF373B4A3", - "content": "Hello world", - "createdAt": 1635505671, + "content": "hello", + "createdAt": 1635607271, + "likes": 3, + "userPictureUrl": "https://lh3.googleusercontent.com/a-/AOh14Ggi0fsy3DrKGf2CswW96pft9BO3tDskxr0241FmxQ=s96-c", "author": { - "id": "EA8E23FC-8F12-404A-9A68-576FBDF72934", - "name": "George Technoman" + "id": "jans.tuomi@gmail.com", + "name": "Jan Tuomi" }, - "likes": 38 + "id": "1" }, { "content": "asd", - "createdAt": 1635513526, - "likes": 2, - "author": { - "id": "logged-in-user-id", - "name": "Logged in user" - }, - "id": "-_5iRYd" - }, - { - "content": "new post :)))", - "createdAt": 1635513654, - "likes": 1, - "author": { - "id": "logged-in-user-id", - "name": "Logged in user" - }, - "id": "sgiJnlL" - }, - { - "content": "I'll have two number nines", - "createdAt": 1635514095, - "likes": 1, - "author": { - "id": "logged-in-user-id", - "name": "Logged in user" - }, - "id": "g5Vggns" - }, - { - "content": "asdasdasd", - "createdAt": 1635514147, + "createdAt": 1635607657, "likes": 0, + "userPictureUrl": "https://lh3.googleusercontent.com/a-/AOh14Ggi0fsy3DrKGf2CswW96pft9BO3tDskxr0241FmxQ=s96-c", "author": { - "id": "logged-in-user-id", - "name": "Logged in user" + "id": "jans.tuomi@gmail.com", + "name": "Jan Tuomi" }, - "id": "bIZavcS" + "id": "3B9scEm" }, { - "content": "asdasdas", - "createdAt": 1635514155, + "content": "throwaway account here :---))", + "createdAt": 1635610312, "likes": 0, + "userPictureUrl": "https://s.gravatar.com/avatar/ef587429f8bb85b585c901c528cacca9?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fnj.png", "author": { - "id": "logged-in-user-id", - "name": "Logged in user" + "id": "njezhmeteiexjqoviz@pp7rvv.com", + "name": "njezhmeteiexjqoviz@pp7rvv.com" }, - "id": "bsb19RF" + "id": "sKnOARV" }, { - "content": "asdasd", - "createdAt": 1635514207, - "likes": 2, - "author": { - "id": "logged-in-user-id", - "name": "Logged in user" - }, - "id": "miEWafM" - }, - { - "content": "plö lö", - "createdAt": 1635516837, - "likes": 5, - "author": { - "id": "logged-in-user-id", - "name": "Logged in user" - }, - "id": "A44uzWi" - }, - { - "content": "asdasd", - "createdAt": 1635526426, - "likes": 3, + "content": "asd", + "createdAt": 1635610687, + "likes": 0, + "userPictureUrl": "https://s.gravatar.com/avatar/ef587429f8bb85b585c901c528cacca9?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fnj.png", "author": { - "id": "logged-in-user-id", - "name": "Logged in user" + "id": "njezhmeteiexjqoviz@pp7rvv.com", + "name": "njezhmeteiexjqoviz@pp7rvv.com" }, - "id": "_wepGww" + "id": "0nOVGJM" }, { - "content": "pippeli", - "createdAt": 1635526491, - "likes": 1, + "content": "foobar", + "createdAt": 1635610870, + "likes": 0, + "userPictureUrl": "https://lh3.googleusercontent.com/a-/AOh14Ggi0fsy3DrKGf2CswW96pft9BO3tDskxr0241FmxQ=s96-c", "author": { - "id": "logged-in-user-id", - "name": "Logged in user" + "id": "jans.tuomi@gmail.com", + "name": "Jan Tuomi" }, - "id": "E3th4Nq" + "id": "JvXaf8k" } ] } \ No newline at end of file diff --git a/main.js b/main.js index bc89f99..a7aae91 100644 --- a/main.js +++ b/main.js @@ -51,53 +51,61 @@ const configureClient = async() => { window.auth0 = auth0; }; -// Will run when page finishes loading -window.onload = async() => { +const authenticateIfNeeded = async() => { await configureClient(); - // If unable to parse the history hash, default to the root URL - // if (!showContentFromUrl(window.location.pathname)) { - // showContentFromUrl("/"); - // window.history.replaceState({ url: "/" }, {}, "/"); - // } - - const isAuthenticated = await auth0.isAuthenticated(); + const userDataExists = await auth0.isAuthenticated(); - if (isAuthenticated) { + if (userDataExists) { + // If user is in browser state, use that user data console.log("> User is authenticated"); window.history.replaceState({}, document.title, window.location.pathname); - // runElmApp(); return; } console.log("> User not authenticated"); + // No user data in browser, try checking the query params for callback info const query = window.location.search; const shouldParseResult = query.includes("code=") && query.includes("state="); if (shouldParseResult) { + // If URL contains callback query params, parse them and authenticate console.log("> Parsing redirect"); try { - const result = await auth0.handleRedirectCallback(); - - if (result.appState && result.appState.targetUrl) { - showContentFromUrl(result.appState.targetUrl); - } + await auth0.handleRedirectCallback(); console.log("Logged in!"); - return runElmApp(); + + window.history.replaceState({}, document.title, window.location.pathname); + return; } catch (err) { console.log("Error parsing redirect:", err); } } + // No data to authenticate with, start login flow and redirect login(); +} + +// Will run when page finishes loading +window.onload = async() => { + await authenticateIfNeeded(); + await runElmApp(); }; -const runElmApp = () => { +const runElmApp = async() => { try { + const user = await auth0.getUser(); + const flags = { + email: user.email, + name: user.name, + pictureUrl: user.picture, + }; + const app = Elm.Main.init({ - node: document.getElementById("app") + node: document.getElementById("app"), + flags, }); app.ports.showAlert.subscribe((message) => window.alert(message)); diff --git a/src/Config.elm b/src/Config.elm index ecd263b..397bc38 100644 --- a/src/Config.elm +++ b/src/Config.elm @@ -1,5 +1,7 @@ module Config exposing (ApiResource(..), makeApiUrl) +import Utils exposing (templ) + baseUrl : String baseUrl = @@ -20,8 +22,8 @@ makeApiUrl res = "{baseUrl}/posts?_sort=createdAt&_order=desc" ApiLikePost postId -> - "{baseUrl}/posts/{postId}" - |> String.replace "{postId}" postId + "{baseUrl}/posts/{0}" + |> templ [ postId ] ApiCompose -> "{baseUrl}/posts" diff --git a/src/Header.elm b/src/Header.elm new file mode 100644 index 0000000..257b308 --- /dev/null +++ b/src/Header.elm @@ -0,0 +1,34 @@ +module Header exposing (..) + +import Css exposing (..) +import Html.Styled exposing (Html, button, div, text) +import Html.Styled.Attributes exposing (css) +import Html.Styled.Events exposing (onClick) +import Types exposing (Model, Msg(..)) +import Utils exposing (templ) + + +styles = + { container = + css + [ displayFlex + , flexFlow2 row wrap + , justifyContent flexEnd + , width (pct 100) + , marginBottom (px 10) + ] + , logoutButton = + css + [ marginLeft (px 10) + , cursor pointer + ] + } + + +headerView : Model -> List (Html Msg) +headerView model = + [ div [ styles.container ] + [ div [] [ "Logged in as {0}" |> templ [ model.userData.name ] |> text ] + , button [ styles.logoutButton, onClick RequestLogout ] [ text "Logout" ] + ] + ] diff --git a/src/Main.elm b/src/Main.elm index 9d55d9c..9f5b699 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -3,19 +3,21 @@ port module Main exposing (..) import Browser exposing (Document) import Browser.Navigation exposing (Key) import Config exposing (makeApiUrl) +import Header exposing (headerView) import Html.Styled exposing (..) -import Html.Styled.Events exposing (onClick) import Http -import Json.Decode exposing (Decoder, field, int, list, map2, map5, string) +import Json.Decode exposing (Decoder, field, int, list, map2, map6, string) import Json.Encode import Pages.Feed exposing (feedView) +import RemoteData import Task import Time -import Types exposing (Author, Model, Msg(..), Post) +import Types exposing (Author, Model, Msg(..), Post, UserData) import Url exposing (Url) +import Utils exposing (httpErrorToString, listFlat) -main : Program () Model Msg +main : Program UserData Model Msg main = Browser.application { init = init @@ -41,13 +43,18 @@ port requestLogout : () -> Cmd msg -- INIT -init : () -> Url -> Key -> ( Model, Cmd Msg ) -init _ _ _ = - ( { posts = Nothing - , now = Time.millisToPosix 0 +init : UserData -> Url -> Key -> ( Model, Cmd Msg ) +init userData _ key = + ( { now = Time.millisToPosix 0 + , key = key + , userData = userData + , posts = RemoteData.Loading , composeInputValue = "" } - , Cmd.batch [ getPosts, Task.perform SetNowPosix Time.now ] + , Cmd.batch + [ getPosts + , Task.perform SetNowPosix Time.now + ] ) @@ -71,10 +78,10 @@ update msg model = ( model, getPosts ) GotPosts (Result.Ok posts) -> - ( { model | posts = Just posts }, Cmd.none ) + ( { model | posts = RemoteData.Success posts }, Cmd.none ) GotPosts (Result.Err httpErr) -> - ( model + ( { model | posts = RemoteData.Failure httpErr } , showAlert <| httpErrorToString httpErr ) @@ -82,7 +89,7 @@ update msg model = ( model, likePost post ) LikedPost (Result.Ok post) -> - ( { model | posts = Just <| replaceMatchingPost post (Maybe.withDefault [] model.posts) } + ( { model | posts = RemoteData.map (replaceMatchingPost post) model.posts } , Cmd.none ) @@ -105,7 +112,7 @@ update msg model = ComposedPost (Result.Ok post) -> ( { model - | posts = Maybe.map (\posts -> post :: posts) model.posts + | posts = RemoteData.map (\posts -> post :: posts) model.posts , composeInputValue = "" } , Cmd.none @@ -166,10 +173,11 @@ composePost model = [ ( "content", Json.Encode.string model.composeInputValue ) , ( "createdAt", Json.Encode.int <| Time.posixToMillis model.now // 1000 ) , ( "likes", Json.Encode.int 0 ) + , ( "userPictureUrl", Json.Encode.string model.userData.pictureUrl ) , ( "author" , Json.Encode.object - [ ( "id", Json.Encode.string "logged-in-user-id" ) - , ( "name", Json.Encode.string "Logged in user" ) + [ ( "id", Json.Encode.string model.userData.email ) + , ( "name", Json.Encode.string model.userData.name ) ] ) ] @@ -202,7 +210,7 @@ postsDecoder = postDecoder : Decoder Post postDecoder = - map5 Post + map6 Post (field "id" string) (field "content" string) (field "author" @@ -213,6 +221,7 @@ postDecoder = ) (field "createdAt" decodeTime) (field "likes" int) + (field "userPictureUrl" string) @@ -222,39 +231,8 @@ postDecoder = view : Model -> Document Msg view model = { title = "Shoob book" - , body = button [ onClick RequestLogout ] [ text "Logout" ] :: feedView model |> List.map toUnstyled + , body = + [ headerView model, feedView model ] + |> listFlat + |> List.map toUnstyled } - - - --- UTILS - - -httpErrorToString : Http.Error -> String -httpErrorToString err = - case err of - Http.BadUrl url -> - "URL {0} is invalid" |> templ [ url ] - - Http.Timeout -> - "Request has timed out" - - Http.NetworkError -> - "Unable to reach the server, check your network connection" - - Http.BadStatus status -> - "Server responded with status {0}" |> templ [ String.fromInt status ] - - Http.BadBody msg -> - msg - - -templ : List String -> String -> String -templ rs original = - let - templElement_ : ( Int, String ) -> String -> String - templElement_ ( index, r ) orig_ = - String.replace ("{" ++ String.fromInt index ++ "}") r orig_ - in - List.indexedMap Tuple.pair rs - |> List.foldl templElement_ original diff --git a/src/Pages/Feed.elm b/src/Pages/Feed.elm index 824552a..7fbe98a 100644 --- a/src/Pages/Feed.elm +++ b/src/Pages/Feed.elm @@ -3,18 +3,16 @@ module Pages.Feed exposing (..) import Css exposing (..) import DateFormat.Relative exposing (relativeTime) import Html.Styled exposing (..) -import Html.Styled.Attributes exposing (css, placeholder, type_, value) +import Html.Styled.Attributes exposing (css, placeholder, src, type_, value) import Html.Styled.Events exposing (onClick, onInput, onSubmit) +import RemoteData exposing (RemoteData(..)) import Time exposing (now) import Types exposing (Model, Msg(..), Post) styles = { page = - css - [ maxWidth (px 600) - , margin2 (px 10) auto - ] + css [ margin2 (px 10) auto ] , postList = css [ overflow scroll @@ -27,6 +25,16 @@ styles = , backgroundColor (hex "#eee") , marginBottom (px 10) ] + , postProfilePicture = + css + [ width (px 40) + , height (px 40) + ] + , postHeader = + css + [ display inlineBlock + , marginLeft (px 10) + ] , postTitle = css [ margin (px 0) ] @@ -50,9 +58,13 @@ styles = postDiv : Time.Posix -> Post -> Html Msg postDiv now post = div [ styles.post ] - [ h3 [ styles.postTitle ] [ text post.author.name ] - , i [] - [ text <| relativeTime now post.createdAt + [ span [] + [ img [ styles.postProfilePicture, src post.userPictureUrl ] [] ] + , span [ styles.postHeader ] + [ h3 [ styles.postTitle ] [ text post.author.name ] + , i [] + [ text <| relativeTime now post.createdAt + ] ] , p [] [ text post.content ] , span [] @@ -65,12 +77,18 @@ postDiv now post = postsDiv : Model -> Html Msg postsDiv model = case model.posts of - Just posts -> - div [ styles.postList ] <| List.map (postDiv model.now) posts + Initial -> + div [] [] - Nothing -> + Loading -> div [] [ text "Loading posts..." ] + Failure _ -> + div [] [] + + Success posts -> + div [ styles.postList ] <| List.map (postDiv model.now) posts + composeDiv : Model -> Html Msg composeDiv model = diff --git a/src/RemoteData.elm b/src/RemoteData.elm new file mode 100644 index 0000000..7173851 --- /dev/null +++ b/src/RemoteData.elm @@ -0,0 +1,52 @@ +module RemoteData exposing (..) + +import Http + + +type RemoteData a + = Initial + | Loading + | Failure Http.Error + | Success a + + +withDefault : a -> RemoteData a -> a +withDefault default rd = + case rd of + Success value -> + value + + _ -> + default + + +map : (a -> b) -> RemoteData a -> RemoteData b +map fn rd = + case rd of + Initial -> + Initial + + Loading -> + Loading + + Failure err -> + Failure err + + Success value -> + Success (fn value) + + +andThen : (a -> RemoteData b) -> RemoteData a -> RemoteData b +andThen callback rd = + case rd of + Initial -> + Initial + + Loading -> + Loading + + Failure err -> + Failure err + + Success value -> + callback value diff --git a/src/Types.elm b/src/Types.elm index ce487b6..d5068d6 100644 --- a/src/Types.elm +++ b/src/Types.elm @@ -1,13 +1,24 @@ module Types exposing (..) +import Browser.Navigation exposing (Key) import Http +import RemoteData exposing (RemoteData) import Time type alias Model = - { posts : Maybe (List Post) + { now : Time.Posix + , key : Key + , userData : UserData + , posts : RemoteData (List Post) , composeInputValue : String - , now : Time.Posix + } + + +type alias UserData = + { name : String + , email : String + , pictureUrl : String } @@ -39,4 +50,5 @@ type alias Post = , author : Author , createdAt : Time.Posix , likes : Int + , userPictureUrl : String } diff --git a/src/Utils.elm b/src/Utils.elm new file mode 100644 index 0000000..3da2c74 --- /dev/null +++ b/src/Utils.elm @@ -0,0 +1,42 @@ +module Utils exposing (..) + +import Http + + + +-- UTILS + + +httpErrorToString : Http.Error -> String +httpErrorToString err = + case err of + Http.BadUrl url -> + "URL {0} is invalid" |> templ [ url ] + + Http.Timeout -> + "Request has timed out" + + Http.NetworkError -> + "Unable to reach the server, check your network connection" + + Http.BadStatus status -> + "Server responded with status {0}" |> templ [ String.fromInt status ] + + Http.BadBody msg -> + msg + + +templ : List String -> String -> String +templ rs original = + let + templElement_ : ( Int, String ) -> String -> String + templElement_ ( index, r ) orig_ = + String.replace (String.replace "n" (String.fromInt index) "{n}") r orig_ + in + List.indexedMap Tuple.pair rs + |> List.foldl templElement_ original + + +listFlat : List (List a) -> List a +listFlat ll = + List.foldl (\acc l -> l ++ acc) [] ll -- cgit v1.3