all repos

onasty @ 36f59cd12d0992b16f20057c23cfe0e3c338e637

a one-time notes service

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

Olexandr Smirnov Olexandr Smirnov
ss2316544@gmail.com
refactor: require only email for resending verification email (#165)..., 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
                }
128
            )
129
130
        UserChangedFormVariant variant ->
131
            ( { model | formVariant = variant }, Effect.none )
132
133
        UserUpdatedInput Email email ->
134
            ( { model | email = email }, Effect.none )
135
136
        UserUpdatedInput Password password ->
137
            ( { model | password = password }, Effect.none )
138
139
        UserUpdatedInput PasswordAgain passwordAgain ->
140
            ( { model | passwordAgain = passwordAgain }, Effect.none )
141
142
        ApiSignInResponded (Ok credentials) ->
143
            ( { model | isSubmittingForm = False }, Effect.signin credentials )
144
145
        ApiSignInResponded (Err error) ->
146
            if Api.isNotVerified error then
147
                ( { model | isSubmittingForm = False, apiError = Nothing, showVerifyBanner = True }, Effect.none )
148
149
            else
150
                ( { model | isSubmittingForm = False, apiError = Just error }, Effect.none )
151
152
        ApiSignUpResponded (Ok ()) ->
153
            ( { model | isSubmittingForm = False, showVerifyBanner = True }, Effect.none )
154
155
        ApiSignUpResponded (Err error) ->
156
            ( { model | isSubmittingForm = False, apiError = Just error }, Effect.none )
157
158
        ApiResendVerificationEmail (Ok ()) ->
159
            ( { model | apiError = Nothing }, Effect.none )
160
161
        ApiResendVerificationEmail (Err err) ->
162
            ( { model | apiError = Just err }, Effect.none )
163
164
165
166
-- SUBSCRIPTIONS
167
168
169
subscriptions : Model -> Sub Msg
170
subscriptions model =
171
    if model.showVerifyBanner then
172
        Time.every 1000 Tick
173
174
    else
175
        Sub.none
176
177
178
179
-- VIEW
180
181
182
view : Model -> View Msg
183
view model =
184
    { title = "Authentication"
185
    , body =
186
        [ H.div [ A.class "min-h-screen flex items-center justify-center bg-gray-50 p-4" ]
187
            [ H.div [ A.class "w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-sm" ]
188
                -- TODO: add oauth buttons
189
                [ viewBanner model
190
                , viewHeader model.formVariant
191
                , H.div [ A.class "px-6 pb-6 space-y-4" ]
192
                    [ viewChangeVariant model.formVariant
193
                    , H.div [ A.class "border-t border-gray-200" ] []
194
                    , viewForm model
195
                    ]
196
                ]
197
            ]
198
        ]
199
    }
200
201
202
viewBanner : Model -> Html Msg
203
viewBanner model =
204
    case ( model.apiError, model.showVerifyBanner ) of
205
        ( Just error, False ) ->
206
            Components.Error.error (Api.errorMessage error)
207
208
        ( Nothing, True ) ->
209
            viewVerificationBanner model.now model.lastClicked
210
211
        _ ->
212
            H.text ""
213
214
215
viewVerificationBanner : Maybe Posix -> Maybe Posix -> Html Msg
216
viewVerificationBanner now lastClicked =
217
    let
218
        buttonClassesBase =
219
            "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"
220
221
        buttonClasses active =
222
            if active then
223
                buttonClassesBase ++ " border border-gray-300 text-gray-700 hover:bg-gray-50"
224
225
            else
226
                buttonClassesBase ++ " border border-gray-300 text-gray-400 cursor-not-allowed"
227
228
        timeLeftSeconds =
229
            case ( now, lastClicked ) of
230
                ( Just now_, Just last ) ->
231
                    let
232
                        elapsedMs =
233
                            Time.posixToMillis now_ - Time.posixToMillis last
234
                    in
235
                    max 0 ((30 * 1000 - elapsedMs) // 1000)
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 "Please verify your account to continue. We've sent a verification link to your email — click it 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
        , Components.Utils.viewIf (not canClick)
254
            (H.p
255
                [ A.class "text-gray-600 text-xs mt-2" ]
256
                [ H.text ("You can request a new verification email in " ++ String.fromInt timeLeftSeconds ++ " seconds.") ]
257
            )
258
        ]
259
260
261
viewHeader : Variant -> Html Msg
262
viewHeader variant =
263
    let
264
        ( title, description ) =
265
            case variant of
266
                SignIn ->
267
                    ( "Welcome Back", "Enter your credentials to access your account" )
268
269
                SignUp ->
270
                    ( "Create Account", "Enter your information to create your account" )
271
    in
272
    H.div [ A.class "p-6 pb-4" ]
273
        [ H.h1 [ A.class "text-2xl font-bold text-center mb-2" ] [ H.text title ]
274
        , H.p [ A.class "text-center text-gray-600 text-sm" ] [ H.text description ]
275
        ]
276
277
278
viewChangeVariant : Variant -> Html Msg
279
viewChangeVariant variant =
280
    let
281
        buttonClasses active =
282
            let
283
                base =
284
                    "flex-1 px-4 py-2 rounded-md font-medium transition-colors"
285
            in
286
            if active then
287
                base ++ " bg-black text-white"
288
289
            else
290
                base ++ " bg-white text-black border border-gray-300 hover:bg-gray-50"
291
    in
292
    H.div [ A.class "flex gap-2" ]
293
        [ H.button
294
            [ A.class (buttonClasses (variant == SignIn))
295
            , A.disabled (variant == SignIn)
296
            , E.onClick (UserChangedFormVariant SignIn)
297
            ]
298
            [ H.text "Sign In" ]
299
        , H.button
300
            [ A.class (buttonClasses (variant == SignUp))
301
            , A.disabled (variant == SignUp)
302
            , E.onClick (UserChangedFormVariant SignUp)
303
            ]
304
            [ H.text "Sign Up" ]
305
        ]
306
307
308
viewForm : Model -> Html Msg
309
viewForm model =
310
    H.form
311
        [ A.class "space-y-4"
312
        , E.onSubmit UserClickedSubmit
313
        ]
314
        (case model.formVariant of
315
            SignIn ->
316
                [ viewFormInput { field = Email, value = model.email }
317
                , viewFormInput { field = Password, value = model.password }
318
                , viewForgotPassword
319
                , viewSubmitButton model
320
                ]
321
322
            SignUp ->
323
                [ viewFormInput { field = Email, value = model.email }
324
                , viewFormInput { field = Password, value = model.password }
325
                , viewFormInput { field = PasswordAgain, value = model.passwordAgain }
326
                , viewSubmitButton model
327
                ]
328
        )
329
330
331
viewFormInput : { field : Field, value : String } -> Html Msg
332
viewFormInput opts =
333
    Components.Form.input
334
        { id = fromFieldToInputType opts.field
335
        , field = opts.field
336
        , label = fromFieldToLabel opts.field
337
        , type_ = fromFieldToInputType opts.field
338
        , value = opts.value
339
        , placeholder = fromFieldToLabel opts.field
340
        , required = True
341
        , onInput = UserUpdatedInput opts.field
342
        , helpText = Nothing
343
        , prefix = Nothing
344
        }
345
346
347
viewForgotPassword : Html Msg
348
viewForgotPassword =
349
    H.div [ A.class "text-right" ]
350
        [ H.button
351
            [ A.class "text-sm text-black hover:underline focus:outline-none"
352
            , A.type_ "button"
353
354
            -- TODO: implement forgot password
355
            -- , E.onClick (UserChangedFormVariant ForgotPassword)
356
            ]
357
            [ H.text "Forgot password?" ]
358
        ]
359
360
361
viewSubmitButton : Model -> Html Msg
362
viewSubmitButton model =
363
    H.button
364
        [ A.type_ "submit"
365
        , A.disabled (isFormDisabled model)
366
        , A.class
367
            (if isFormDisabled model then
368
                "w-full px-4 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors"
369
370
             else
371
                "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"
372
            )
373
        ]
374
        [ H.text (fromVariantToLabel model.formVariant) ]
375
376
377
isFormDisabled : Model -> Bool
378
isFormDisabled model =
379
    case model.formVariant of
380
        SignIn ->
381
            model.isSubmittingForm
382
                || String.isEmpty model.email
383
                || String.isEmpty model.password
384
385
        SignUp ->
386
            model.isSubmittingForm
387
                || String.isEmpty model.email
388
                || String.isEmpty model.password
389
                || String.isEmpty model.passwordAgain
390
                || (model.password /= model.passwordAgain)
391
392
393
fromVariantToLabel : Variant -> String
394
fromVariantToLabel variant =
395
    case variant of
396
        SignIn ->
397
            "Sign In"
398
399
        SignUp ->
400
            "Sign Up"
401
402
403
fromFieldToLabel : Field -> String
404
fromFieldToLabel field =
405
    case field of
406
        Email ->
407
            "Email address"
408
409
        Password ->
410
            "Password"
411
412
        PasswordAgain ->
413
            "Confirm password"
414
415
416
fromFieldToInputType : Field -> String
417
fromFieldToInputType field =
418
    case field of
419
        Email ->
420
            "email"
421
422
        Password ->
423
            "password"
424
425
        PasswordAgain ->
426
            "password"