all repos

onasty @ aebfa02

a one-time notes service

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

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