all repos

onasty @ ef386ed36d8e402f1424cd3663fa9ad8b25c3eb9

a one-time notes service

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

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