all repos

onasty @ 083ffe6

a one-time notes service

onasty/web/src/Pages/Auth.elm (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat(web): add oauth login (#213)..., 8 months ago
1
module Pages.Auth exposing (Banner, FormVariant, Model, Msg, page)
2
3
import Api
4
import Api.Auth
5
import Auth.User
6
import Components.Box
7
import Components.Form
8
import Components.Utils
9
import Data.Credentials exposing (Credentials)
10
import Dict
11
import Effect exposing (Effect)
12
import Html as H exposing (Html)
13
import Html.Attributes as A
14
import Html.Events as E
15
import Layouts
16
import Page exposing (Page)
17
import Route exposing (Route)
18
import Route.Path
19
import Shared
20
import Time exposing (Posix)
21
import Validators
22
import View exposing (View)
23
24
25
page : Shared.Model -> Route () -> Page Model Msg
26
page shared route =
27
    Page.new
28
        { init = init shared route
29
        , update = update
30
        , subscriptions = subscriptions
31
        , view = view
32
        }
33
        |> Page.withLayout (\_ -> Layouts.Header {})
34
35
36
37
-- INIT
38
39
40
type alias Model =
41
    { email : String
42
    , password : String
43
    , passwordAgain : String
44
    , isSubmittingForm : Bool
45
    , banner : Banner
46
    , formVariant : FormVariant
47
    , lastClicked : Maybe Posix
48
    , now : Maybe Posix
49
    }
50
51
52
init : Shared.Model -> Route () -> () -> ( Model, Effect Msg )
53
init shared route () =
54
    let
55
        formVariant =
56
            case Dict.get "token" route.query of
57
                Just token ->
58
                    SetNewPassword token
59
60
                Nothing ->
61
                    SignIn
62
    in
63
    ( { formVariant = formVariant
64
      , isSubmittingForm = False
65
      , email = ""
66
      , password = ""
67
      , passwordAgain = ""
68
      , lastClicked = Nothing
69
      , banner = Hidden
70
      , now = Nothing
71
      }
72
    , case shared.user of
73
        Auth.User.SignedIn _ ->
74
            Effect.pushRoutePath Route.Path.Home_
75
76
        _ ->
77
            Effect.none
78
    )
79
80
81
82
-- UPDATE
83
84
85
type Msg
86
    = Tick Posix
87
    | UserUpdatedInput Field String
88
    | UserChangedFormVariant FormVariant
89
    | UserClickedSubmit
90
    | UserClickedResendActivationEmail
91
    | UserClickedOAuth Provider
92
    | ApiSignInResponded (Result Api.Error Credentials)
93
    | ApiSignUpResponded (Result Api.Error ())
94
    | ApiForgotPasswordResponded (Result Api.Error ())
95
    | ApiSetNewPasswordResponded (Result Api.Error ())
96
    | ApiResendVerificationEmail (Result Api.Error ())
97
98
99
type Provider
100
    = Github
101
    | Google
102
103
104
type Field
105
    = Email
106
    | Password
107
    | PasswordAgain
108
109
110
type alias ResetPasswordToken =
111
    String
112
113
114
type FormVariant
115
    = SignIn
116
    | SignUp
117
    | ForgotPassword
118
    | SetNewPassword ResetPasswordToken
119
120
121
type Banner
122
    = Hidden
123
    | ResendVerificationEmail
124
    | Error Api.Error
125
    | CheckEmail
126
127
128
update : Msg -> Model -> ( Model, Effect Msg )
129
update msg model =
130
    case msg of
131
        Tick now ->
132
            ( { model | now = Just now }, Effect.none )
133
134
        UserClickedSubmit ->
135
            ( { model | isSubmittingForm = True }
136
            , case model.formVariant of
137
                SignIn ->
138
                    Api.Auth.signin
139
                        { onResponse = ApiSignInResponded
140
                        , email = model.email
141
                        , password = model.password
142
                        }
143
144
                SignUp ->
145
                    Api.Auth.signup
146
                        { onResponse = ApiSignUpResponded
147
                        , email = model.email
148
                        , password = model.password
149
                        }
150
151
                ForgotPassword ->
152
                    Api.Auth.forgotPassword { onResponse = ApiForgotPasswordResponded, email = model.email }
153
154
                SetNewPassword token ->
155
                    Api.Auth.resetPassword { onResponse = ApiSetNewPasswordResponded, token = token, password = model.password }
156
            )
157
158
        UserClickedOAuth provider ->
159
            case provider of
160
                Github ->
161
                    ( model, Effect.loadExternalUrl "/api/v1/oauth/github" )
162
163
                Google ->
164
                    ( model, Effect.loadExternalUrl "/api/v1/oauth/google" )
165
166
        UserClickedResendActivationEmail ->
167
            ( { model | lastClicked = model.now }
168
            , Api.Auth.resendVerificationEmail
169
                { onResponse = ApiResendVerificationEmail
170
                , email = model.email
171
                }
172
            )
173
174
        UserChangedFormVariant variant ->
175
            ( { model | formVariant = variant }, Effect.none )
176
177
        UserUpdatedInput Email email ->
178
            ( { model | email = email }, Effect.none )
179
180
        UserUpdatedInput Password password ->
181
            ( { model | password = password }, Effect.none )
182
183
        UserUpdatedInput PasswordAgain passwordAgain ->
184
            ( { model | passwordAgain = passwordAgain }, Effect.none )
185
186
        ApiSignInResponded (Ok credentials) ->
187
            ( { model | isSubmittingForm = False }, Effect.signin credentials )
188
189
        ApiSignInResponded (Err err) ->
190
            if Api.isNotVerified err then
191
                ( { model | isSubmittingForm = False, banner = ResendVerificationEmail }, Effect.none )
192
193
            else
194
                ( { model | isSubmittingForm = False, banner = Error err }, Effect.none )
195
196
        ApiSignUpResponded (Ok ()) ->
197
            ( { model | isSubmittingForm = False, banner = ResendVerificationEmail }, Effect.none )
198
199
        ApiSignUpResponded (Err err) ->
200
            ( { model | isSubmittingForm = False, banner = Error err }, Effect.none )
201
202
        ApiResendVerificationEmail (Ok ()) ->
203
            ( model, Effect.none )
204
205
        ApiResendVerificationEmail (Err err) ->
206
            ( { model | banner = Error err }, Effect.none )
207
208
        ApiSetNewPasswordResponded (Ok ()) ->
209
            ( { model | isSubmittingForm = False, formVariant = SignIn, password = "", passwordAgain = "" }, Effect.replaceRoutePath Route.Path.Auth )
210
211
        ApiSetNewPasswordResponded (Err err) ->
212
            ( { model | isSubmittingForm = False, banner = Error err }, Effect.none )
213
214
        ApiForgotPasswordResponded (Ok ()) ->
215
            ( { model | isSubmittingForm = False, banner = CheckEmail }, Effect.none )
216
217
        ApiForgotPasswordResponded (Err err) ->
218
            ( { model | isSubmittingForm = False, banner = Error err }, Effect.none )
219
220
221
subscriptions : Model -> Sub Msg
222
subscriptions model =
223
    if model.banner == ResendVerificationEmail then
224
        Time.every 1000 Tick
225
226
    else
227
        Sub.none
228
229
230
231
-- VIEW
232
233
234
view : Model -> View Msg
235
view model =
236
    { title = "Authentication"
237
    , body =
238
        [ H.div [ A.class "min-h-screen flex items-center justify-center bg-gray-50 p-4" ]
239
            [ H.div [ A.class "w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-sm" ]
240
                [ viewBanner model
241
                , viewBoxHeader model.formVariant
242
                , H.div [ A.class "px-6 pb-6 space-y-4" ]
243
                    [ viewChangeVariant model.formVariant
244
                    , viewOauthButtons model.isSubmittingForm
245
                    , H.div [ A.class "border-t border-gray-200" ] []
246
                    , viewForm model
247
                    ]
248
                ]
249
            ]
250
        ]
251
    }
252
253
254
viewBanner : Model -> Html Msg
255
viewBanner model =
256
    case model.banner of
257
        Hidden ->
258
            H.text ""
259
260
        Error err ->
261
            Components.Box.error (Api.errorMessage err)
262
263
        CheckEmail ->
264
            Components.Box.success { header = "Check your email!", body = "To continue with resetting your password please check the email we've sent." }
265
266
        ResendVerificationEmail ->
267
            viewVerificationBanner model.now model.lastClicked
268
269
270
viewVerificationBanner : Maybe Posix -> Maybe Posix -> Html Msg
271
viewVerificationBanner now lastClicked =
272
    let
273
        timeLeftSeconds =
274
            case ( now, lastClicked ) of
275
                ( Just now_, Just last ) ->
276
                    let
277
                        elapsedMs =
278
                            Time.posixToMillis now_ - Time.posixToMillis last
279
                    in
280
                    max 0 ((30 * 1000 - elapsedMs) // 1000)
281
282
                _ ->
283
                    0
284
285
        canClick : Bool
286
        canClick =
287
            timeLeftSeconds == 0
288
    in
289
    Components.Box.successBox
290
        [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text "Check your email!" ]
291
        , H.p [ A.class "text-green-800 text-sm" ] [ H.text "Please verify your account to continue. We've sent a verification link to your email — click it to activate your account." ]
292
        , Components.Form.button
293
            { text = "Resend verification email"
294
            , onClick = UserClickedResendActivationEmail
295
            , disabled = not canClick
296
            , style = Components.Form.SecondaryDisabled canClick
297
            }
298
        , Components.Utils.viewIf (not canClick)
299
            (H.p [ A.class "text-gray-600 text-xs mt-2" ]
300
                [ H.text ("You can request a new verification email in " ++ String.fromInt timeLeftSeconds ++ " seconds.") ]
301
            )
302
        ]
303
304
305
viewBoxHeader : FormVariant -> Html Msg
306
viewBoxHeader variant =
307
    let
308
        ( title, description ) =
309
            case variant of
310
                SignIn ->
311
                    ( "Welcome Back", "Enter your credentials to access your account" )
312
313
                SignUp ->
314
                    ( "Create Account", "Enter your information to create your account" )
315
316
                ForgotPassword ->
317
                    ( "Forgot Password", "Enter your email to reset your password" )
318
319
                SetNewPassword _ ->
320
                    ( "Set New Password", "Enter your new password to reset your account" )
321
    in
322
    H.div [ A.class "p-6 pb-4" ]
323
        [ H.h1 [ A.class "text-2xl font-bold text-center mb-2" ] [ H.text title ]
324
        , H.p [ A.class "text-center text-gray-600 text-sm" ] [ H.text description ]
325
        ]
326
327
328
viewChangeVariant : FormVariant -> Html Msg
329
viewChangeVariant variant =
330
    H.div [ A.class "flex [&>*]:flex-1 gap-2" ]
331
        [ Components.Form.button
332
            { text = "Sign In"
333
            , onClick = UserChangedFormVariant SignIn
334
            , style = Components.Form.Primary (variant == SignIn)
335
            , disabled = variant == SignIn
336
            }
337
        , Components.Form.button
338
            { text = "Sign Up"
339
            , disabled = variant == SignUp
340
            , style = Components.Form.Primary (variant == SignUp)
341
            , onClick = UserChangedFormVariant SignUp
342
            }
343
        ]
344
345
346
viewOauthButtons : Bool -> Html Msg
347
viewOauthButtons isSubmittingForm =
348
    H.div []
349
        [ Components.Form.oauthButton
350
            { text = "Sign in with Google"
351
            , onClick = UserClickedOAuth Google
352
            , disabled = isSubmittingForm
353
            , iconURL = "/static/google.svg"
354
            }
355
        , Components.Form.oauthButton
356
            { text = "Sign in with GitHub"
357
            , onClick = UserClickedOAuth Github
358
            , disabled = isSubmittingForm
359
            , iconURL = "/static/github.svg"
360
            }
361
        ]
362
363
364
viewForm : Model -> Html Msg
365
viewForm model =
366
    H.form
367
        [ A.class "space-y-4"
368
        , E.onSubmit UserClickedSubmit
369
        ]
370
        (case model.formVariant of
371
            SignIn ->
372
                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }
373
                , viewFormInput { field = Password, value = model.password, error = Validators.password model.password }
374
                , viewForgotPassword
375
                , viewSubmitButton model
376
                ]
377
378
            SignUp ->
379
                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }
380
                , viewFormInput { field = Password, value = model.password, error = Validators.password model.password }
381
                , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = Validators.passwords model.password model.passwordAgain }
382
                , viewSubmitButton model
383
                ]
384
385
            ForgotPassword ->
386
                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }
387
                , viewSubmitButton model
388
                ]
389
390
            SetNewPassword _ ->
391
                [ viewFormInput { field = Password, value = model.password, error = Validators.password model.password }
392
                , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = Validators.passwords model.password model.passwordAgain }
393
                , viewSubmitButton model
394
                ]
395
        )
396
397
398
viewFormInput : { field : Field, value : String, error : Maybe String } -> Html Msg
399
viewFormInput opts =
400
    Components.Form.input
401
        { style = Components.Form.Simple
402
        , id = (fromFieldToFieldInfo opts.field).label
403
        , error = opts.error
404
        , label = (fromFieldToFieldInfo opts.field).label
405
        , type_ = (fromFieldToFieldInfo opts.field).type_
406
        , placeholder = (fromFieldToFieldInfo opts.field).label
407
        , onInput = UserUpdatedInput opts.field
408
        , field = opts.field
409
        , value = opts.value
410
        , required = True
411
        }
412
413
414
viewForgotPassword : Html Msg
415
viewForgotPassword =
416
    H.div [ A.class "text-right" ]
417
        [ H.button
418
            [ A.class "text-sm text-black hover:underline focus:outline-none"
419
            , A.type_ "button"
420
            , E.onClick (UserChangedFormVariant ForgotPassword)
421
            ]
422
            [ H.text "Forgot password?" ]
423
        ]
424
425
426
viewSubmitButton : Model -> Html Msg
427
viewSubmitButton model =
428
    Components.Form.submitButton
429
        { class = "w-full"
430
        , text = fromVariantToLabel model.formVariant
431
        , style = Components.Form.Primary (isFormDisabled model)
432
        , disabled = isFormDisabled model
433
        }
434
435
436
isFormDisabled : Model -> Bool
437
isFormDisabled model =
438
    case model.formVariant of
439
        SignIn ->
440
            model.isSubmittingForm
441
                || (Validators.email model.email /= Nothing)
442
                || (Validators.password model.password /= Nothing)
443
444
        SignUp ->
445
            model.isSubmittingForm
446
                || (Validators.email model.email /= Nothing)
447
                || (Validators.password model.password /= Nothing)
448
                || (Validators.passwords model.password model.passwordAgain /= Nothing)
449
450
        ForgotPassword ->
451
            model.isSubmittingForm || (Validators.email model.email /= Nothing)
452
453
        SetNewPassword _ ->
454
            model.isSubmittingForm
455
                || (Validators.email model.email /= Nothing)
456
                || (Validators.password model.password /= Nothing)
457
                || (Validators.passwords model.password model.passwordAgain /= Nothing)
458
459
460
fromVariantToLabel : FormVariant -> String
461
fromVariantToLabel variant =
462
    case variant of
463
        SignIn ->
464
            "Sign In"
465
466
        SignUp ->
467
            "Sign Up"
468
469
        ForgotPassword ->
470
            "Forgot Password"
471
472
        SetNewPassword _ ->
473
            "Set new password"
474
475
476
fromFieldToFieldInfo : Field -> { label : String, type_ : String }
477
fromFieldToFieldInfo field =
478
    case field of
479
        Email ->
480
            { label = "Email address", type_ = "email" }
481
482
        Password ->
483
            { label = "Password", type_ = "password" }
484
485
        PasswordAgain ->
486
            { label = "Confirm password", type_ = "password" }