all repos

onasty @ 164a37ba21c3c0c386d8d388b5e54844e3991f62

a one-time notes service

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

Olexandr Smirnov Olexandr Smirnov
ss2316544@gmail.com
feat(web): add account settings (#190)..., 9 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
    | ApiSignInResponded (Result Api.Error Credentials)
92
    | ApiSignUpResponded (Result Api.Error ())
93
    | ApiForgotPasswordResponded (Result Api.Error ())
94
    | ApiSetNewPasswordResponded (Result Api.Error ())
95
    | ApiResendVerificationEmail (Result Api.Error ())
96
97
98
type Field
99
    = Email
100
    | Password
101
    | PasswordAgain
102
103
104
type alias ResetPasswordToken =
105
    String
106
107
108
type FormVariant
109
    = SignIn
110
    | SignUp
111
    | ForgotPassword
112
    | SetNewPassword ResetPasswordToken
113
114
115
type Banner
116
    = Hidden
117
    | ResendVerificationEmail
118
    | Error Api.Error
119
    | CheckEmail
120
121
122
update : Msg -> Model -> ( Model, Effect Msg )
123
update msg model =
124
    case msg of
125
        Tick now ->
126
            ( { model | now = Just now }, Effect.none )
127
128
        UserClickedSubmit ->
129
            ( { model | isSubmittingForm = True }
130
            , case model.formVariant of
131
                SignIn ->
132
                    Api.Auth.signin
133
                        { onResponse = ApiSignInResponded
134
                        , email = model.email
135
                        , password = model.password
136
                        }
137
138
                SignUp ->
139
                    Api.Auth.signup
140
                        { onResponse = ApiSignUpResponded
141
                        , email = model.email
142
                        , password = model.password
143
                        }
144
145
                ForgotPassword ->
146
                    Api.Auth.forgotPassword { onResponse = ApiForgotPasswordResponded, email = model.email }
147
148
                SetNewPassword token ->
149
                    Api.Auth.resetPassword { onResponse = ApiSetNewPasswordResponded, token = token, password = model.password }
150
            )
151
152
        UserClickedResendActivationEmail ->
153
            ( { model | lastClicked = model.now }
154
            , Api.Auth.resendVerificationEmail
155
                { onResponse = ApiResendVerificationEmail
156
                , email = model.email
157
                }
158
            )
159
160
        UserChangedFormVariant variant ->
161
            ( { model | formVariant = variant }, Effect.none )
162
163
        UserUpdatedInput Email email ->
164
            ( { model | email = email }, Effect.none )
165
166
        UserUpdatedInput Password password ->
167
            ( { model | password = password }, Effect.none )
168
169
        UserUpdatedInput PasswordAgain passwordAgain ->
170
            ( { model | passwordAgain = passwordAgain }, Effect.none )
171
172
        ApiSignInResponded (Ok credentials) ->
173
            ( { model | isSubmittingForm = False }, Effect.signin credentials )
174
175
        ApiSignInResponded (Err err) ->
176
            if Api.isNotVerified err then
177
                ( { model | isSubmittingForm = False, banner = ResendVerificationEmail }, Effect.none )
178
179
            else
180
                ( { model | isSubmittingForm = False, banner = Error err }, Effect.none )
181
182
        ApiSignUpResponded (Ok ()) ->
183
            ( { model | isSubmittingForm = False, banner = ResendVerificationEmail }, Effect.none )
184
185
        ApiSignUpResponded (Err err) ->
186
            ( { model | isSubmittingForm = False, banner = Error err }, Effect.none )
187
188
        ApiResendVerificationEmail (Ok ()) ->
189
            ( model, Effect.none )
190
191
        ApiResendVerificationEmail (Err err) ->
192
            ( { model | banner = Error err }, Effect.none )
193
194
        ApiSetNewPasswordResponded (Ok ()) ->
195
            ( { model | isSubmittingForm = False, formVariant = SignIn, password = "", passwordAgain = "" }, Effect.replaceRoutePath Route.Path.Auth )
196
197
        ApiSetNewPasswordResponded (Err err) ->
198
            ( { model | isSubmittingForm = False, banner = Error err }, Effect.none )
199
200
        ApiForgotPasswordResponded (Ok ()) ->
201
            ( { model | isSubmittingForm = False, banner = CheckEmail }, Effect.none )
202
203
        ApiForgotPasswordResponded (Err err) ->
204
            ( { model | isSubmittingForm = False, banner = Error err }, Effect.none )
205
206
207
subscriptions : Model -> Sub Msg
208
subscriptions model =
209
    if model.banner == ResendVerificationEmail then
210
        Time.every 1000 Tick
211
212
    else
213
        Sub.none
214
215
216
217
-- VIEW
218
219
220
view : Model -> View Msg
221
view model =
222
    { title = "Authentication"
223
    , body =
224
        [ H.div [ A.class "min-h-screen flex items-center justify-center bg-gray-50 p-4" ]
225
            [ H.div [ A.class "w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-sm" ]
226
                -- TODO: add oauth buttons
227
                [ viewBanner model
228
                , viewBoxHeader model.formVariant
229
                , H.div [ A.class "px-6 pb-6 space-y-4" ]
230
                    [ viewChangeVariant model.formVariant
231
                    , H.div [ A.class "border-t border-gray-200" ] []
232
                    , viewForm model
233
                    ]
234
                ]
235
            ]
236
        ]
237
    }
238
239
240
viewBanner : Model -> Html Msg
241
viewBanner model =
242
    case model.banner of
243
        Hidden ->
244
            H.text ""
245
246
        Error err ->
247
            Components.Box.error (Api.errorMessage err)
248
249
        CheckEmail ->
250
            Components.Box.success { header = "Check your email!", body = "To continue with resetting your password please check the email we've sent." }
251
252
        ResendVerificationEmail ->
253
            viewVerificationBanner model.now model.lastClicked
254
255
256
viewVerificationBanner : Maybe Posix -> Maybe Posix -> Html Msg
257
viewVerificationBanner now lastClicked =
258
    let
259
        timeLeftSeconds =
260
            case ( now, lastClicked ) of
261
                ( Just now_, Just last ) ->
262
                    let
263
                        elapsedMs =
264
                            Time.posixToMillis now_ - Time.posixToMillis last
265
                    in
266
                    max 0 ((30 * 1000 - elapsedMs) // 1000)
267
268
                _ ->
269
                    0
270
271
        canClick : Bool
272
        canClick =
273
            timeLeftSeconds == 0
274
    in
275
    Components.Box.successBox
276
        [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text "Check your email!" ]
277
        , 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." ]
278
        , Components.Form.button
279
            { text = "Resend verification email"
280
            , onClick = UserClickedResendActivationEmail
281
            , disabled = not canClick
282
            , style = Components.Form.SecondaryDisabled canClick
283
            }
284
        , Components.Utils.viewIf (not canClick)
285
            (H.p [ A.class "text-gray-600 text-xs mt-2" ]
286
                [ H.text ("You can request a new verification email in " ++ String.fromInt timeLeftSeconds ++ " seconds.") ]
287
            )
288
        ]
289
290
291
viewBoxHeader : FormVariant -> Html Msg
292
viewBoxHeader variant =
293
    let
294
        ( title, description ) =
295
            case variant of
296
                SignIn ->
297
                    ( "Welcome Back", "Enter your credentials to access your account" )
298
299
                SignUp ->
300
                    ( "Create Account", "Enter your information to create your account" )
301
302
                ForgotPassword ->
303
                    ( "Forgot Password", "Enter your email to reset your password" )
304
305
                SetNewPassword _ ->
306
                    ( "Set New Password", "Enter your new password to reset your account" )
307
    in
308
    H.div [ A.class "p-6 pb-4" ]
309
        [ H.h1 [ A.class "text-2xl font-bold text-center mb-2" ] [ H.text title ]
310
        , H.p [ A.class "text-center text-gray-600 text-sm" ] [ H.text description ]
311
        ]
312
313
314
viewChangeVariant : FormVariant -> Html Msg
315
viewChangeVariant variant =
316
    H.div [ A.class "flex [&>*]:flex-1 gap-2" ]
317
        [ Components.Form.button
318
            { text = "Sign In"
319
            , onClick = UserChangedFormVariant SignIn
320
            , style = Components.Form.Primary (variant == SignIn)
321
            , disabled = variant == SignIn
322
            }
323
        , Components.Form.button
324
            { text = "Sign Up"
325
            , disabled = variant == SignUp
326
            , style = Components.Form.Primary (variant == SignUp)
327
            , onClick = UserChangedFormVariant SignUp
328
            }
329
        ]
330
331
332
viewForm : Model -> Html Msg
333
viewForm model =
334
    H.form
335
        [ A.class "space-y-4"
336
        , E.onSubmit UserClickedSubmit
337
        ]
338
        (case model.formVariant of
339
            SignIn ->
340
                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }
341
                , viewFormInput { field = Password, value = model.password, error = Validators.password model.password }
342
                , viewForgotPassword
343
                , viewSubmitButton model
344
                ]
345
346
            SignUp ->
347
                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }
348
                , viewFormInput { field = Password, value = model.password, error = Validators.password model.password }
349
                , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = Validators.passwords model.password model.passwordAgain }
350
                , viewSubmitButton model
351
                ]
352
353
            ForgotPassword ->
354
                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }
355
                , viewSubmitButton model
356
                ]
357
358
            SetNewPassword _ ->
359
                [ viewFormInput { field = Password, value = model.password, error = Validators.password model.password }
360
                , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = Validators.passwords model.password model.passwordAgain }
361
                , viewSubmitButton model
362
                ]
363
        )
364
365
366
viewFormInput : { field : Field, value : String, error : Maybe String } -> Html Msg
367
viewFormInput opts =
368
    Components.Form.input
369
        { style = Components.Form.Simple
370
        , id = (fromFieldToFieldInfo opts.field).label
371
        , error = opts.error
372
        , label = (fromFieldToFieldInfo opts.field).label
373
        , type_ = (fromFieldToFieldInfo opts.field).type_
374
        , placeholder = (fromFieldToFieldInfo opts.field).label
375
        , onInput = UserUpdatedInput opts.field
376
        , field = opts.field
377
        , value = opts.value
378
        , required = True
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
                || (Validators.email model.email /= Nothing)
410
                || (Validators.password model.password /= Nothing)
411
412
        SignUp ->
413
            model.isSubmittingForm
414
                || (Validators.email model.email /= Nothing)
415
                || (Validators.password model.password /= Nothing)
416
                || (Validators.passwords model.password model.passwordAgain /= Nothing)
417
418
        ForgotPassword ->
419
            model.isSubmittingForm || (Validators.email model.email /= Nothing)
420
421
        SetNewPassword _ ->
422
            model.isSubmittingForm
423
                || (Validators.email model.email /= Nothing)
424
                || (Validators.password model.password /= Nothing)
425
                || (Validators.passwords model.password model.passwordAgain /= Nothing)
426
427
428
fromVariantToLabel : FormVariant -> String
429
fromVariantToLabel variant =
430
    case variant of
431
        SignIn ->
432
            "Sign In"
433
434
        SignUp ->
435
            "Sign Up"
436
437
        ForgotPassword ->
438
            "Forgot Password"
439
440
        SetNewPassword _ ->
441
            "Set new password"
442
443
444
fromFieldToFieldInfo : Field -> { label : String, type_ : String }
445
fromFieldToFieldInfo field =
446
    case field of
447
        Email ->
448
            { label = "Email address", type_ = "email" }
449
450
        Password ->
451
            { label = "Password", type_ = "password" }
452
453
        PasswordAgain ->
454
            { label = "Confirm password", type_ = "password" }