all repos

onasty @ 7a1da0fa4be762f19bbb5136f14dcf26bdb922fd

a one-time notes service

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

Olexandr Smirnov Olexandr Smirnov
ss2316544@gmail.com
web: button component; improve code consistency (#168)..., 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 }
340
                , viewFormInput { field = Password, value = model.password }
341
                , viewForgotPassword
342
                , viewSubmitButton model
343
                ]
344
345
            SignUp ->
346
                [ viewFormInput { field = Email, value = model.email }
347
                , viewFormInput { field = Password, value = model.password }
348
                , viewFormInput { field = PasswordAgain, value = model.passwordAgain }
349
                , viewSubmitButton model
350
                ]
351
352
            ForgotPassword ->
353
                [ viewFormInput { field = Email, value = model.email }
354
                , viewSubmitButton model
355
                ]
356
357
            SetNewPassword token ->
358
                [ viewFormInput { field = Password, value = model.password }
359
                , viewFormInput { field = PasswordAgain, value = model.passwordAgain }
360
                , H.input [ A.type_ "hidden", A.value token, A.name "token" ] []
361
                , viewSubmitButton model
362
                ]
363
        )
364
365
366
viewFormInput : { field : Field, value : String } -> Html Msg
367
viewFormInput opts =
368
    Components.Form.input
369
        { id = (fromFieldToFieldInfo opts.field).label
370
        , field = opts.field
371
        , label = (fromFieldToFieldInfo opts.field).label
372
        , type_ = (fromFieldToFieldInfo opts.field).type_
373
        , value = opts.value
374
        , placeholder = (fromFieldToFieldInfo opts.field).label
375
        , required = True
376
        , onInput = UserUpdatedInput opts.field
377
        , helpText = Nothing
378
        , prefix = Nothing
379
        }
380
381
382
viewForgotPassword : Html Msg
383
viewForgotPassword =
384
    H.div [ A.class "text-right" ]
385
        [ H.button
386
            [ A.class "text-sm text-black hover:underline focus:outline-none"
387
            , A.type_ "button"
388
            , E.onClick (UserChangedFormVariant ForgotPassword)
389
            ]
390
            [ H.text "Forgot password?" ]
391
        ]
392
393
394
viewSubmitButton : Model -> Html Msg
395
viewSubmitButton model =
396
    Components.Form.submitButton
397
        { class = "w-full"
398
        , text = fromVariantToLabel model.formVariant
399
        , style = Components.Form.Primary (isFormDisabled model)
400
        , disabled = isFormDisabled model
401
        }
402
403
404
isFormDisabled : Model -> Bool
405
isFormDisabled model =
406
    case model.formVariant of
407
        SignIn ->
408
            model.isSubmittingForm
409
                || String.isEmpty model.email
410
                || String.isEmpty model.password
411
412
        SignUp ->
413
            model.isSubmittingForm
414
                || String.isEmpty model.email
415
                || String.isEmpty model.password
416
                || String.isEmpty model.passwordAgain
417
                || (model.password /= model.passwordAgain)
418
419
        ForgotPassword ->
420
            model.isSubmittingForm || String.isEmpty model.email
421
422
        SetNewPassword _ ->
423
            model.isSubmittingForm
424
                || String.isEmpty model.password
425
                || String.isEmpty model.passwordAgain
426
                || (model.password /= model.passwordAgain)
427
428
429
fromVariantToLabel : FormVariant -> String
430
fromVariantToLabel variant =
431
    case variant of
432
        SignIn ->
433
            "Sign In"
434
435
        SignUp ->
436
            "Sign Up"
437
438
        ForgotPassword ->
439
            "Forgot Password"
440
441
        SetNewPassword _ ->
442
            "Set new password"
443
444
445
fromFieldToFieldInfo : Field -> { label : String, type_ : String }
446
fromFieldToFieldInfo field =
447
    case field of
448
        Email ->
449
            { label = "Email address", type_ = "email" }
450
451
        Password ->
452
            { label = "Password", type_ = "password" }
453
454
        PasswordAgain ->
455
            { label = "Confirm password", type_ = "password" }