├── README.md ├── elm-package.json ├── gulpfile.js ├── package.json └── src ├── Main.elm ├── index.html └── styles.css /README.md: -------------------------------------------------------------------------------- 1 | # Creating Your First Elm App: From Authentication to Calling an API 2 | 3 | This is the source code repository for: 4 | 5 | [Creating Your First Elm App: From Authentication to Calling an API (Part 1)](https://auth0.com/blog/creating-your-first-elm-app-part-1) 6 | 7 | [Creating Your First Elm App: From Authentication to Calling an API (Part 2)](https://auth0.com/blog/creating-your-first-elm-app-part-2) 8 | 9 | Tutorial steps for `Main.elm` are available as branches. 10 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "summary": "Build an App in Elm with JWT Authentication and an API", 4 | "repository": "https://github.com/YiMihi/elm-app-jwt-api.git", 5 | "license": "MIT", 6 | "source-directories": [ 7 | "src", 8 | "dist" 9 | ], 10 | "exposed-modules": [], 11 | "dependencies": { 12 | "elm-lang/core": "4.0.1 <= v < 5.0.0", 13 | "elm-lang/html": "1.0.0 <= v < 2.0.0", 14 | "evancz/elm-http": "3.0.1 <= v < 4.0.0", 15 | "rgrempel/elm-http-decorators": "1.0.2 <= v < 2.0.0" 16 | }, 17 | "elm-version": "0.17.0 <= v < 0.18.0" 18 | } 19 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var elm = require('gulp-elm'); 3 | var gutil = require('gulp-util'); 4 | var plumber = require('gulp-plumber'); 5 | var connect = require('gulp-connect'); 6 | 7 | // File paths 8 | var paths = { 9 | dest: 'dist', 10 | elm: 'src/*.elm', 11 | static: 'src/*.{html,css}' 12 | }; 13 | 14 | // Init Elm 15 | gulp.task('elm-init', elm.init); 16 | 17 | // Compile Elm 18 | gulp.task('elm', ['elm-init'], function(){ 19 | return gulp.src(paths.elm) 20 | .pipe(plumber()) 21 | .pipe(elm()) 22 | .pipe(gulp.dest(paths.dest)); 23 | }); 24 | 25 | // Move static assets to dist 26 | gulp.task('static', function() { 27 | return gulp.src(paths.static) 28 | .pipe(plumber()) 29 | .pipe(gulp.dest(paths.dest)); 30 | }); 31 | 32 | // Watch for changes and compile 33 | gulp.task('watch', function() { 34 | gulp.watch(paths.elm, ['elm']); 35 | gulp.watch(paths.static, ['static']); 36 | }); 37 | 38 | // Local server 39 | gulp.task('connect', function() { 40 | connect.server({ 41 | root: 'dist', 42 | port: 3000 43 | }); 44 | }); 45 | 46 | // Main gulp tasks 47 | gulp.task('build', ['elm', 'static']); 48 | gulp.task('default', ['connect', 'build', 'watch']); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elm-app-jwt-api", 3 | "version": "0.1.0", 4 | "author": "Kim Maida", 5 | "description": "Authenticating an Elm App with JWT", 6 | "main": "", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/YiMihi/elm-app-jwt-api.git" 10 | }, 11 | "keywords": [ 12 | "elm" 13 | ], 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/YiMihi/elm-app-jwt-api/issues" 17 | }, 18 | "scripts": { 19 | "dev": "gulp", 20 | "build": "gulp build" 21 | }, 22 | "homepage": "https://github.com/YiMihi/elm-app-jwt-api", 23 | "dependencies": {}, 24 | "devDependencies": { 25 | "gulp": "^3.9.0", 26 | "gulp-connect": "^4.0.0", 27 | "gulp-elm": "^0.4.4", 28 | "gulp-plumber": "^1.1.0", 29 | "gulp-util": "^3.0.7" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.App as Html 5 | import Html.Events exposing (..) 6 | import Html.Attributes exposing (..) 7 | import String 8 | 9 | import Http 10 | import Http.Decorators 11 | import Task exposing (Task) 12 | import Json.Decode as Decode exposing (..) 13 | import Json.Encode as Encode exposing (..) 14 | 15 | main : Program (Maybe Model) 16 | main = 17 | Html.programWithFlags 18 | { init = init 19 | , update = update 20 | , subscriptions = \_ -> Sub.none 21 | , view = view 22 | } 23 | 24 | {- 25 | MODEL 26 | * Model type 27 | * Initialize model with empty values 28 | * Initialize with a random quote 29 | -} 30 | 31 | type alias Model = 32 | { username : String 33 | , password : String 34 | , token : String 35 | , quote : String 36 | , protectedQuote : String 37 | , errorMsg : String 38 | } 39 | 40 | init : Maybe Model -> (Model, Cmd Msg) 41 | init model = 42 | case model of 43 | Just model -> 44 | ( model, fetchRandomQuoteCmd ) 45 | 46 | Nothing -> 47 | ( Model "" "" "" "" "" "", fetchRandomQuoteCmd ) 48 | 49 | {- 50 | UPDATE 51 | * API routes 52 | * GET and POST 53 | * Encode request body 54 | * Decode responses 55 | * Messages 56 | * Ports 57 | * Update case 58 | -} 59 | 60 | -- API request URLs 61 | 62 | api : String 63 | api = 64 | "http://localhost:3001/" 65 | 66 | randomQuoteUrl : String 67 | randomQuoteUrl = 68 | api ++ "api/random-quote" 69 | 70 | registerUrl : String 71 | registerUrl = 72 | api ++ "users" 73 | 74 | loginUrl : String 75 | loginUrl = 76 | api ++ "sessions/create" 77 | 78 | protectedQuoteUrl : String 79 | protectedQuoteUrl = 80 | api ++ "api/protected/random-quote" 81 | 82 | -- GET a random quote (unauthenticated) 83 | 84 | fetchRandomQuote : Platform.Task Http.Error String 85 | fetchRandomQuote = 86 | Http.getString randomQuoteUrl 87 | 88 | fetchRandomQuoteCmd : Cmd Msg 89 | fetchRandomQuoteCmd = 90 | Task.perform HttpError FetchQuoteSuccess fetchRandomQuote 91 | 92 | -- Encode user to construct POST request body (for Register and Log In) 93 | 94 | userEncoder : Model -> Encode.Value 95 | userEncoder model = 96 | Encode.object 97 | [ ("username", Encode.string model.username) 98 | , ("password", Encode.string model.password) 99 | ] 100 | 101 | -- POST register / login request 102 | 103 | authUser : Model -> String -> Task Http.Error String 104 | authUser model apiUrl = 105 | { verb = "POST" 106 | , headers = [ ("Content-Type", "application/json") ] 107 | , url = apiUrl 108 | , body = Http.string <| Encode.encode 0 <| userEncoder model 109 | } 110 | |> Http.send Http.defaultSettings 111 | |> Http.fromJson tokenDecoder 112 | 113 | authUserCmd : Model -> String -> Cmd Msg 114 | authUserCmd model apiUrl = 115 | Task.perform AuthError GetTokenSuccess <| authUser model apiUrl 116 | 117 | -- Decode POST response to get token 118 | 119 | tokenDecoder : Decoder String 120 | tokenDecoder = 121 | "id_token" := Decode.string 122 | 123 | -- GET request for random protected quote (authenticated) 124 | 125 | fetchProtectedQuote : Model -> Task Http.Error String 126 | fetchProtectedQuote model = 127 | { verb = "GET" 128 | , headers = [ ("Authorization", "Bearer " ++ model.token) ] 129 | , url = protectedQuoteUrl 130 | , body = Http.empty 131 | } 132 | |> Http.send Http.defaultSettings 133 | |> Http.Decorators.interpretStatus -- decorates Http.send result so error type is Http.Error instead of RawError 134 | |> Task.map responseText 135 | 136 | fetchProtectedQuoteCmd : Model -> Cmd Msg 137 | fetchProtectedQuoteCmd model = 138 | Task.perform HttpError FetchProtectedQuoteSuccess <| fetchProtectedQuote model 139 | 140 | -- Extract GET plain text response to get protected quote 141 | 142 | responseText : Http.Response -> String 143 | responseText response = 144 | case response.value of 145 | Http.Text t -> 146 | t 147 | _ -> 148 | "" 149 | 150 | -- Helper to update model and set localStorage with the updated model 151 | 152 | setStorageHelper : Model -> ( Model, Cmd Msg ) 153 | setStorageHelper model = 154 | ( model, setStorage model ) 155 | 156 | -- Messages 157 | 158 | type Msg 159 | = GetQuote 160 | | FetchQuoteSuccess String 161 | | HttpError Http.Error 162 | | AuthError Http.Error 163 | | SetUsername String 164 | | SetPassword String 165 | | ClickRegisterUser 166 | | ClickLogIn 167 | | GetTokenSuccess String 168 | | GetProtectedQuote 169 | | FetchProtectedQuoteSuccess String 170 | | LogOut 171 | 172 | -- Ports 173 | 174 | port setStorage : Model -> Cmd msg 175 | port removeStorage : Model -> Cmd msg 176 | 177 | -- Update 178 | 179 | update : Msg -> Model -> (Model, Cmd Msg) 180 | update msg model = 181 | case msg of 182 | GetQuote -> 183 | ( model, fetchRandomQuoteCmd ) 184 | 185 | FetchQuoteSuccess newQuote -> 186 | ( { model | quote = newQuote }, Cmd.none ) 187 | 188 | HttpError _ -> 189 | ( model, Cmd.none ) 190 | 191 | AuthError error -> 192 | ( { model | errorMsg = (toString error) }, Cmd.none ) 193 | 194 | SetUsername username -> 195 | ( { model | username = username }, Cmd.none ) 196 | 197 | SetPassword password -> 198 | ( { model | password = password }, Cmd.none ) 199 | 200 | ClickRegisterUser -> 201 | ( model, authUserCmd model registerUrl ) 202 | 203 | ClickLogIn -> 204 | ( model, authUserCmd model loginUrl ) 205 | 206 | GetTokenSuccess newToken -> 207 | setStorageHelper { model | token = newToken, password = "", errorMsg = "" } 208 | 209 | GetProtectedQuote -> 210 | ( model, fetchProtectedQuoteCmd model ) 211 | 212 | FetchProtectedQuoteSuccess newPQuote -> 213 | setStorageHelper { model | protectedQuote = newPQuote } 214 | 215 | LogOut -> 216 | ( { model | username = "", protectedQuote = "", token = "" }, removeStorage model ) 217 | 218 | {- 219 | VIEW 220 | * Hide sections of view depending on authenticaton state of model 221 | * Get a quote 222 | * Log In or Register 223 | * Get a protected quote 224 | -} 225 | 226 | view : Model -> Html Msg 227 | view model = 228 | let 229 | -- Is the user logged in? 230 | loggedIn : Bool 231 | loggedIn = 232 | if String.length model.token > 0 then True else False 233 | 234 | -- If the user is logged in, show a greeting; if logged out, show the login/register form 235 | authBoxView = 236 | let 237 | -- If there is an error on authentication, show the error alert 238 | showError : String 239 | showError = 240 | if String.isEmpty model.errorMsg then "hidden" else "" 241 | 242 | -- Greet a logged in user by username 243 | greeting : String 244 | greeting = 245 | "Hello, " ++ model.username ++ "!" 246 | 247 | in 248 | if loggedIn then 249 | div [id "greeting" ][ 250 | h3 [ class "text-center" ] [ text greeting ] 251 | , p [ class "text-center" ] [ text "You have super-secret access to protected quotes." ] 252 | , p [ class "text-center" ] [ 253 | button [ class "btn btn-danger", onClick LogOut ] [ text "Log Out" ] 254 | ] 255 | ] 256 | else 257 | div [ id "form" ] [ 258 | h2 [ class "text-center" ] [ text "Log In or Register" ] 259 | , p [ class "help-block" ] [ text "If you already have an account, please Log In. Otherwise, enter your desired username and password and Register." ] 260 | , div [ class showError ] [ 261 | div [ class "alert alert-danger" ] [ text model.errorMsg ] 262 | ] 263 | , div [ class "form-group row" ] [ 264 | div [ class "col-md-offset-2 col-md-8" ] [ 265 | label [ for "username" ] [ text "Username:" ] 266 | , input [ id "username", type' "text", class "form-control", Html.Attributes.value model.username, onInput SetUsername ] [] 267 | ] 268 | ] 269 | , div [ class "form-group row" ] [ 270 | div [ class "col-md-offset-2 col-md-8" ] [ 271 | label [ for "password" ] [ text "Password:" ] 272 | , input [ id "password", type' "password", class "form-control", Html.Attributes.value model.password, onInput SetPassword ] [] 273 | ] 274 | ] 275 | , div [ class "text-center" ] [ 276 | button [ class "btn btn-primary", onClick ClickLogIn ] [ text "Log In" ] 277 | , button [ class "btn btn-link", onClick ClickRegisterUser ] [ text "Register" ] 278 | ] 279 | ] 280 | 281 | -- If user is logged in, show button and quote; if logged out, show a message instructing them to log in 282 | protectedQuoteView = 283 | let 284 | -- If no protected quote, apply a class of "hidden" 285 | hideIfNoProtectedQuote : String 286 | hideIfNoProtectedQuote = 287 | if String.isEmpty model.protectedQuote then "hidden" else "" 288 | 289 | in 290 | if loggedIn then 291 | div [] [ 292 | p [ class "text-center" ] [ 293 | button [ class "btn btn-info", onClick GetProtectedQuote ] [ text "Grab a protected quote!" ] 294 | ] 295 | -- Blockquote with protected quote: only show if a protectedQuote is present in model 296 | , blockquote [ class hideIfNoProtectedQuote ] [ 297 | p [] [text model.protectedQuote] 298 | ] 299 | ] 300 | else 301 | p [ class "text-center" ] [ text "Please log in or register to see protected quotes." ] 302 | 303 | in 304 | div [ class "container" ] [ 305 | h2 [ class "text-center" ] [ text "Chuck Norris Quotes" ] 306 | , p [ class "text-center" ] [ 307 | button [ class "btn btn-success", onClick GetQuote ] [ text "Grab a quote!" ] 308 | ] 309 | -- Blockquote with quote 310 | , blockquote [] [ 311 | p [] [text model.quote] 312 | ] 313 | , div [ class "jumbotron text-left" ] [ 314 | -- Login/Register form or user greeting 315 | authBoxView 316 | ], div [] [ 317 | h2 [ class "text-center" ] [ text "Protected Chuck Norris Quotes" ] 318 | -- Protected quotes 319 | , protectedQuoteView 320 | ] 321 | ] -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Chuck Norris Quoter 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 1em auto; 3 | max-width: 600px; 4 | } 5 | blockquote { 6 | margin: 1em 0; 7 | } 8 | .jumbotron { 9 | margin: 2em auto; 10 | max-width: 400px; 11 | } 12 | .jumbotron h2 { 13 | margin-top: 0; 14 | } 15 | .jumbotron .help-block { 16 | font-size: 14px; 17 | } --------------------------------------------------------------------------------