all repos

onasty @ 39d6b8e

a one-time notes service

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

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