all repos

onasty @ b59aa76595abf2171bde3a5a97551f1f008aa622

a one-time notes service

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

Smirnov Oleksandr Smirnov Oleksandr
ss2316544@gmail.com
web: create note page (#144)..., 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 : String
214
        buttonClassesBase =
215
            "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"
216
217
        buttonClasses : Bool -> String
218
        buttonClasses active =
219
            if active then
220
                buttonClassesBase ++ " border border-gray-300 text-gray-700 hover:bg-gray-50"
221
222
            else
223
                buttonClassesBase ++ " border border-gray-300 text-gray-400 cursor-not-allowed"
224
225
        timeLeftSeconds : Int
226
        timeLeftSeconds =
227
            case ( now, lastClicked ) of
228
                ( Just now_, Just last ) ->
229
                    let
230
                        remainingMs : Int
231
                        remainingMs =
232
                            30 * 1000 - (Time.posixToMillis now_ - Time.posixToMillis last)
233
                    in
234
                    if remainingMs > 0 then
235
                        remainingMs // 1000
236
237
                    else
238
                        0
239
240
                _ ->
241
                    0
242
243
        canClick : Bool
244
        canClick =
245
            timeLeftSeconds == 0
246
    in
247
    H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-4 mb-4" ]
248
        [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text "Check your email!" ]
249
        , 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." ]
250
        , H.button
251
            [ A.class (buttonClasses canClick)
252
            , E.onClick UserClickedResendActivationEmail
253
            , A.disabled (not canClick)
254
            ]
255
            [ H.text "Resend verification email" ]
256
        , if canClick then
257
            H.text ""
258
259
          else
260
            H.p [ A.class "text-gray-600 text-xs mt-2" ]
261
                [ H.text
262
                    ("You can request a new verification email in "
263
                        ++ String.fromInt timeLeftSeconds
264
                        ++ " seconds."
265
                    )
266
                ]
267
        ]
268
269
270
viewHeader : Variant -> Html Msg
271
viewHeader variant =
272
    let
273
        ( title, description ) =
274
            case variant of
275
                SignIn ->
276
                    ( "Welcome Back", "Enter your credentials to access your account" )
277
278
                SignUp ->
279
                    ( "Create Account", "Enter your information to create your account" )
280
    in
281
    H.div [ A.class "p-6 pb-4" ]
282
        [ H.h1 [ A.class "text-2xl font-bold text-center mb-2" ] [ H.text title ]
283
        , H.p [ A.class "text-center text-gray-600 text-sm" ] [ H.text description ]
284
        ]
285
286
287
viewChangeVariant : Variant -> Html Msg
288
viewChangeVariant variant =
289
    let
290
        base : String
291
        base =
292
            "flex-1 px-4 py-2 rounded-md font-medium transition-colors"
293
294
        buttonClasses : Bool -> String
295
        buttonClasses active =
296
            if active then
297
                base ++ " bg-black text-white"
298
299
            else
300
                base ++ " bg-white text-black border border-gray-300 hover:bg-gray-50"
301
    in
302
    H.div [ A.class "flex gap-2" ]
303
        [ H.button
304
            [ A.class (buttonClasses (variant == SignIn))
305
            , A.disabled (variant == SignIn)
306
            , E.onClick (UserChangedFormVariant SignIn)
307
            ]
308
            [ H.text "Sign In" ]
309
        , H.button
310
            [ A.class (buttonClasses (variant == SignUp))
311
            , A.disabled (variant == SignUp)
312
            , E.onClick (UserChangedFormVariant SignUp)
313
            ]
314
            [ H.text "Sign Up" ]
315
        ]
316
317
318
viewForm : Model -> Html Msg
319
viewForm model =
320
    H.form
321
        [ A.class "space-y-4"
322
        , E.onSubmit UserClickedSubmit
323
        ]
324
        (case model.formVariant of
325
            SignIn ->
326
                [ viewFormInput { field = Email, value = model.email }
327
                , viewFormInput { field = Password, value = model.password }
328
                , viewForgotPassword
329
                , viewSubmitButton model
330
                ]
331
332
            SignUp ->
333
                [ viewFormInput { field = Email, value = model.email }
334
                , viewFormInput { field = Password, value = model.password }
335
                , viewFormInput { field = PasswordAgain, value = model.passwordAgain }
336
                , viewSubmitButton model
337
                ]
338
        )
339
340
341
viewFormInput : { field : Field, value : String } -> Html Msg
342
viewFormInput opts =
343
    H.div [ A.class "space-y-2" ]
344
        [ H.label
345
            [ A.class "block text-sm font-medium text-gray-700" ]
346
            [ H.text (fromFieldToLabel opts.field) ]
347
        , H.div []
348
            [ H.input
349
                [ 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"
350
                , A.type_ (fromFieldToInputType opts.field)
351
                , A.value opts.value
352
                , A.placeholder (fromFieldToLabel opts.field)
353
                , A.required True
354
                , E.onInput (UserUpdatedInput opts.field)
355
                ]
356
                []
357
            ]
358
        ]
359
360
361
viewForgotPassword : Html Msg
362
viewForgotPassword =
363
    H.div [ A.class "text-right" ]
364
        [ H.button
365
            [ A.class "text-sm text-black hover:underline focus:outline-none"
366
            , A.type_ "button"
367
368
            -- TODO: implement forgot password
369
            -- , E.onClick (UserChangedFormVariant ForgotPassword)
370
            ]
371
            [ H.text "Forgot password?" ]
372
        ]
373
374
375
viewSubmitButton : Model -> Html Msg
376
viewSubmitButton model =
377
    H.button
378
        [ A.type_ "submit"
379
        , A.disabled (isFormDisabled model)
380
        , A.class
381
            (if isFormDisabled model then
382
                "w-full px-4 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors"
383
384
             else
385
                "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"
386
            )
387
        ]
388
        [ H.text (fromVariantToLabel model.formVariant) ]
389
390
391
isFormDisabled : Model -> Bool
392
isFormDisabled model =
393
    case model.formVariant of
394
        SignIn ->
395
            model.isSubmittingForm
396
                || String.isEmpty model.email
397
                || String.isEmpty model.password
398
399
        SignUp ->
400
            model.isSubmittingForm
401
                || String.isEmpty model.email
402
                || String.isEmpty model.password
403
                || String.isEmpty model.passwordAgain
404
                || (model.password /= model.passwordAgain)
405
406
407
fromVariantToLabel : Variant -> String
408
fromVariantToLabel variant =
409
    case variant of
410
        SignIn ->
411
            "Sign In"
412
413
        SignUp ->
414
            "Sign Up"
415
416
417
fromFieldToLabel : Field -> String
418
fromFieldToLabel field =
419
    case field of
420
        Email ->
421
            "Email address"
422
423
        Password ->
424
            "Password"
425
426
        PasswordAgain ->
427
            "Confirm password"
428
429
430
fromFieldToInputType : Field -> String
431
fromFieldToInputType field =
432
    case field of
433
        Email ->
434
            "email"
435
436
        Password ->
437
            "password"
438
439
        PasswordAgain ->
440
            "password"