all repos

onasty @ aebfa020925c1ea61ef8579facb5d84aebd0df26

a one-time notes service
4 files changed, 134 insertions(+), 79 deletions(-)
web: form validation (#184)

* web: refactor the input component

* web: show the error

* web: validate form on auth page

* web: validate home page form

* web: refactor, that is not needed

* web: better naming

* web: make the validator more ergonomic

* web: random refactor

* web: first get time zone, then check token expiration(depended on time)
Author: Olexandr Smirnov ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-08-09 17:50:55 +0300
Parent: 7a1da0f
M web/src/Components/Form.elm
···
        1
        
        -module Components.Form exposing (ButtonStyle(..), CanBeClicked, button, input, submitButton)

      
        
        1
        +module Components.Form exposing (ButtonStyle(..), CanBeClicked, InputStyle(..), button, input, submitButton)

      
        2
        2
         

      
        3
        3
         import Html as H exposing (Html)

      
        4
        4
         import Html.Attributes as A

      ···
        9
        9
         -- INPUT

      
        10
        10
         

      
        11
        11
         

      
        
        12
        +type InputStyle

      
        
        13
        +    = Simple

      
        
        14
        +    | Complex

      
        
        15
        +        { prefix : String

      
        
        16
        +        , helpText : String

      
        
        17
        +        }

      
        
        18
        +

      
        
        19
        +

      
        12
        20
         input :

      
        13
        
        -    -- TODO: add `error : Maybe String`, to show that field is not correct and message

      
        14
        21
             { id : String

      
        15
        22
             , field : field

      
        16
        
        -    , label : String

      
        17
        23
             , type_ : String

      
        18
        24
             , value : String

      
        
        25
        +    , label : String

      
        19
        26
             , placeholder : String

      
        20
        27
             , required : Bool

      
        21
        
        -    , helpText : Maybe String

      
        22
        
        -    , prefix : Maybe String

      
        23
        28
             , onInput : String -> msg

      
        
        29
        +    , style : InputStyle

      
        
        30
        +    , error : Maybe String

      
        24
        31
             }

      
        25
        32
             -> Html msg

      
        26
        33
         input opts =

      
        
        34
        +    let

      
        
        35
        +        style =

      
        
        36
        +            case opts.style of

      
        
        37
        +                Simple ->

      
        
        38
        +                    { prefix = H.text "", help = H.text "" }

      
        
        39
        +

      
        
        40
        +                Complex complex ->

      
        
        41
        +                    { prefix = H.span [ A.class "text-gray-500 text-md whitespace-nowrap" ] [ H.text complex.prefix ]

      
        
        42
        +                    , help = H.p [ A.class "text-xs text-gray-500 mt-1" ] [ H.text complex.helpText ]

      
        
        43
        +                    }

      
        
        44
        +

      
        
        45
        +        error =

      
        
        46
        +            case opts.error of

      
        
        47
        +                Nothing ->

      
        
        48
        +                    { element = H.text "", inputAdditionalClasses = "border-gray-300 focus:ring-black " }

      
        
        49
        +

      
        
        50
        +                Just err ->

      
        
        51
        +                    { element = H.p [ A.class "text-red-600 text-xs mt-1" ] [ H.text err ]

      
        
        52
        +                    , inputAdditionalClasses = " border-red-400 focus:ring-red-500"

      
        
        53
        +                    }

      
        
        54
        +    in

      
        27
        55
             H.div [ A.class "space-y-2" ]

      
        28
        56
                 [ H.label

      
        29
        57
                     [ A.for opts.id

      
        30
        58
                     , A.class "block text-sm font-medium text-gray-700"

      
        31
        59
                     ]

      
        32
        60
                     [ H.text opts.label ]

      
        33
        
        -        , H.div

      
        34
        
        -            [ A.class

      
        35
        
        -                (if opts.prefix /= Nothing then

      
        36
        
        -                    "flex items-center"

      
        37
        
        -

      
        38
        
        -                 else

      
        39
        
        -                    ""

      
        40
        
        -                )

      
        41
        
        -            ]

      
        42
        
        -            [ case opts.prefix of

      
        43
        
        -                Just prefix ->

      
        44
        
        -                    H.span [ A.class "text-gray-500 text-md mr-2 whitespace-nowrap" ] [ H.text prefix ]

      
        45
        
        -

      
        46
        
        -                Nothing ->

      
        47
        
        -                    H.text ""

      
        
        61
        +        , H.div [ A.class "flex items-center" ]

      
        
        62
        +            [ style.prefix

      
        48
        63
                     , H.input

      
        49
        
        -                [ 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"

      
        
        64
        +                [ A.class ("w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:border-transparent transition-colors" ++ error.inputAdditionalClasses)

      
        50
        65
                         , A.type_ opts.type_

      
        51
        66
                         , A.value opts.value

      
        52
        67
                         , A.id opts.id

      ···
        56
        71
                         ]

      
        57
        72
                         []

      
        58
        73
                     ]

      
        59
        
        -        , case opts.helpText of

      
        60
        
        -            Just help ->

      
        61
        
        -                H.p [ A.class "text-xs text-gray-500 mt-1" ] [ H.text help ]

      
        62
        
        -

      
        63
        
        -            Nothing ->

      
        64
        
        -                H.text ""

      
        
        74
        +        , error.element

      
        
        75
        +        , style.help

      
        65
        76
                 ]

      
        66
        77
         

      
        67
        78
         

      
M web/src/Pages/Auth.elm
···
        336
        336
                 ]

      
        337
        337
                 (case model.formVariant of

      
        338
        338
                     SignIn ->

      
        339
        
        -                [ viewFormInput { field = Email, value = model.email }

      
        340
        
        -                , viewFormInput { field = Password, value = model.password }

      
        
        339
        +                [ viewFormInput { field = Email, value = model.email, error = validateEmail model.email }

      
        
        340
        +                , viewFormInput { field = Password, value = model.password, error = validatePassword model.password }

      
        341
        341
                         , viewForgotPassword

      
        342
        342
                         , viewSubmitButton model

      
        343
        343
                         ]

      
        344
        344
         

      
        345
        345
                     SignUp ->

      
        346
        
        -                [ viewFormInput { field = Email, value = model.email }

      
        347
        
        -                , viewFormInput { field = Password, value = model.password }

      
        348
        
        -                , viewFormInput { field = PasswordAgain, value = model.passwordAgain }

      
        
        346
        +                [ viewFormInput { field = Email, value = model.email, error = validateEmail model.email }

      
        
        347
        +                , viewFormInput { field = Password, value = model.password, error = validatePassword model.password }

      
        
        348
        +                , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = validatePasswords model.password model.passwordAgain }

      
        349
        349
                         , viewSubmitButton model

      
        350
        350
                         ]

      
        351
        351
         

      
        352
        352
                     ForgotPassword ->

      
        353
        
        -                [ viewFormInput { field = Email, value = model.email }

      
        
        353
        +                [ viewFormInput { field = Email, value = model.email, error = validateEmail model.email }

      
        354
        354
                         , viewSubmitButton model

      
        355
        355
                         ]

      
        356
        356
         

      
        357
        
        -            SetNewPassword token ->

      
        358
        
        -                [ viewFormInput { field = Password, value = model.password }

      
        359
        
        -                , viewFormInput { field = PasswordAgain, value = model.passwordAgain }

      
        360
        
        -                , H.input [ A.type_ "hidden", A.value token, A.name "token" ] []

      
        
        357
        +            SetNewPassword _ ->

      
        
        358
        +                [ viewFormInput { field = Password, value = model.password, error = validatePassword model.password }

      
        
        359
        +                , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = validatePasswords model.password model.passwordAgain }

      
        361
        360
                         , viewSubmitButton model

      
        362
        361
                         ]

      
        363
        362
                 )

      
        364
        363
         

      
        365
        364
         

      
        366
        
        -viewFormInput : { field : Field, value : String } -> Html Msg

      
        
        365
        +viewFormInput : { field : Field, value : String, error : Maybe String } -> Html Msg

      
        367
        366
         viewFormInput opts =

      
        368
        367
             Components.Form.input

      
        369
        
        -        { id = (fromFieldToFieldInfo opts.field).label

      
        370
        
        -        , field = opts.field

      
        
        368
        +        { style = Components.Form.Simple

      
        
        369
        +        , id = (fromFieldToFieldInfo opts.field).label

      
        
        370
        +        , error = opts.error

      
        371
        371
                 , label = (fromFieldToFieldInfo opts.field).label

      
        372
        372
                 , type_ = (fromFieldToFieldInfo opts.field).type_

      
        373
        
        -        , value = opts.value

      
        374
        373
                 , placeholder = (fromFieldToFieldInfo opts.field).label

      
        375
        
        -        , required = True

      
        376
        374
                 , onInput = UserUpdatedInput opts.field

      
        377
        
        -        , helpText = Nothing

      
        378
        
        -        , prefix = Nothing

      
        
        375
        +        , field = opts.field

      
        
        376
        +        , value = opts.value

      
        
        377
        +        , required = True

      
        379
        378
                 }

      
        380
        379
         

      
        381
        380
         

      ···
        406
        405
             case model.formVariant of

      
        407
        406
                 SignIn ->

      
        408
        407
                     model.isSubmittingForm

      
        409
        
        -                || String.isEmpty model.email

      
        410
        
        -                || String.isEmpty model.password

      
        
        408
        +                || (validateEmail model.email /= Nothing)

      
        
        409
        +                || (validatePassword model.password /= Nothing)

      
        411
        410
         

      
        412
        411
                 SignUp ->

      
        413
        412
                     model.isSubmittingForm

      
        414
        
        -                || String.isEmpty model.email

      
        415
        
        -                || String.isEmpty model.password

      
        416
        
        -                || String.isEmpty model.passwordAgain

      
        417
        
        -                || (model.password /= model.passwordAgain)

      
        
        413
        +                || (validateEmail model.email /= Nothing)

      
        
        414
        +                || (validatePassword model.password /= Nothing)

      
        
        415
        +                || (validatePasswords model.password model.passwordAgain /= Nothing)

      
        418
        416
         

      
        419
        417
                 ForgotPassword ->

      
        420
        
        -            model.isSubmittingForm || String.isEmpty model.email

      
        
        418
        +            model.isSubmittingForm || (validateEmail model.email /= Nothing)

      
        421
        419
         

      
        422
        420
                 SetNewPassword _ ->

      
        423
        421
                     model.isSubmittingForm

      
        424
        
        -                || String.isEmpty model.password

      
        425
        
        -                || String.isEmpty model.passwordAgain

      
        426
        
        -                || (model.password /= model.passwordAgain)

      
        
        422
        +                || (validateEmail model.email /= Nothing)

      
        
        423
        +                || (validatePassword model.password /= Nothing)

      
        
        424
        +                || (validatePasswords model.password model.passwordAgain /= Nothing)

      
        
        425
        +

      
        
        426
        +

      
        
        427
        +validateEmail : String -> Maybe String

      
        
        428
        +validateEmail email =

      
        
        429
        +    if

      
        
        430
        +        not (String.isEmpty email)

      
        
        431
        +            && (not (String.contains "@" email) || not (String.contains "." email))

      
        
        432
        +    then

      
        
        433
        +        Just "Please enter a valid email address."

      
        
        434
        +

      
        
        435
        +    else

      
        
        436
        +        Nothing

      
        
        437
        +

      
        
        438
        +

      
        
        439
        +validatePassword : String -> Maybe String

      
        
        440
        +validatePassword passwd =

      
        
        441
        +    if not (String.isEmpty passwd) && String.length passwd < 8 then

      
        
        442
        +        Just "Password must be at least 8 characters long."

      
        
        443
        +

      
        
        444
        +    else

      
        
        445
        +        Nothing

      
        
        446
        +

      
        
        447
        +

      
        
        448
        +validatePasswords : String -> String -> Maybe String

      
        
        449
        +validatePasswords passowrd1 password2 =

      
        
        450
        +    if not (String.isEmpty passowrd1) && passowrd1 /= password2 then

      
        
        451
        +        Just "Passwords do not match."

      
        
        452
        +

      
        
        453
        +    else

      
        
        454
        +        Nothing

      
        427
        455
         

      
        428
        456
         

      
        429
        457
         fromVariantToLabel : FormVariant -> String

      
M web/src/Pages/Home_.elm
···
        241
        241
         

      
        242
        242
         

      
        243
        243
         -- VIEW CREATE NOTE

      
        244
        
        --- TODO: validate the form

      
        245
        244
         

      
        246
        245
         

      
        247
        246
         viewCreateNoteForm : Model -> (String -> String) -> Html Msg

      ···
        252
        251
                 ]

      
        253
        252
                 [ viewTextarea

      
        254
        253
                 , Components.Form.input

      
        255
        
        -            { id = "slug"

      
        
        254
        +            { style =

      
        
        255
        +                Components.Form.Complex

      
        
        256
        +                    { prefix = appUrl ""

      
        
        257
        +                    , helpText = "Leave empty to generate a random slug"

      
        
        258
        +                    }

      
        
        259
        +            , error = validateSlugInput model.slug

      
        256
        260
                     , field = Slug

      
        
        261
        +            , id = "slug"

      
        257
        262
                     , label = "Custom URL Slug (optional)"

      
        258
        
        -            , placeholder = "my-unique-slug"

      
        259
        
        -            , type_ = "text"

      
        260
        
        -            , helpText = Just "Leave empty to generate a random slug"

      
        261
        
        -            , prefix = Just (appUrl "")

      
        262
        263
                     , onInput = UserUpdatedInput Slug

      
        
        264
        +            , placeholder = "my-unique-slug"

      
        263
        265
                     , required = False

      
        
        266
        +            , type_ = "text"

      
        264
        267
                     , value = Maybe.withDefault "" model.slug

      
        265
        268
                     }

      
        266
        269
                 , H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ]

      
        267
        270
                     [ H.div [ A.class "space-y-6" ]

      
        268
        271
                         [ Components.Form.input

      
        269
        
        -                    { id = "password"

      
        
        272
        +                    { style =

      
        
        273
        +                        Components.Form.Complex

      
        
        274
        +                            { prefix = ""

      
        
        275
        +                            , helpText = "Viewers will need this password to access the paste"

      
        
        276
        +                            }

      
        270
        277
                             , field = Password

      
        
        278
        +                    , id = "password"

      
        
        279
        +                    , error = Nothing

      
        271
        280
                             , label = "Password Protection (optional)"

      
        272
        
        -                    , type_ = "password"

      
        
        281
        +                    , onInput = UserUpdatedInput Password

      
        273
        282
                             , placeholder = "Enter password to protect this paste"

      
        274
        
        -                    , helpText = Just "Viewers will need this password to access the paste"

      
        275
        
        -                    , prefix = Nothing

      
        276
        
        -                    , onInput = UserUpdatedInput Password

      
        277
        283
                             , required = False

      
        
        284
        +                    , type_ = "password"

      
        278
        285
                             , value = Maybe.withDefault "" model.password

      
        279
        286
                             }

      
        280
        287
                         ]

      ···
        287
        294
                     [ Components.Form.submitButton

      
        288
        295
                         { text = "Create note"

      
        289
        296
                         , style = Components.Form.Primary (isFormDisabled model)

      
        290
        
        -                , disabled = False

      
        
        297
        +                , disabled = isFormDisabled model

      
        291
        298
                         , class = ""

      
        292
        299
                         }

      
        293
        300
                     ]

      ···
        356
        363
         isFormDisabled : Model -> Bool

      
        357
        364
         isFormDisabled model =

      
        358
        365
             String.isEmpty model.content

      
        
        366
        +        || (validateSlugInput model.slug /= Nothing)

      
        
        367
        +

      
        
        368
        +

      
        
        369
        +validateSlugInput : Maybe String -> Maybe String

      
        
        370
        +validateSlugInput slug =

      
        
        371
        +    let

      
        
        372
        +        value =

      
        
        373
        +            Maybe.withDefault "" slug

      
        
        374
        +    in

      
        
        375
        +    if not (String.isEmpty value) && String.contains " " value then

      
        
        376
        +        Just "Slug cannot contain spaces."

      
        
        377
        +

      
        
        378
        +    else

      
        
        379
        +        Nothing

      
        359
        380
         

      
        360
        381
         

      
        361
        382
         fromFieldToName : Field -> String

      
M web/src/Shared.elm
···
        47
        47
                 flags =

      
        48
        48
                     flagsResult |> Result.withDefault { accessToken = Nothing, refreshToken = Nothing, appUrl = "" }

      
        49
        49
         

      
        50
        
        -        maybeCredentials =

      
        51
        
        -            Maybe.map2 (\access refresh -> { accessToken = access, refreshToken = refresh })

      
        52
        
        -                flags.accessToken

      
        53
        
        -                flags.refreshToken

      
        54
        
        -

      
        55
        50
                 user =

      
        56
        
        -            case maybeCredentials of

      
        
        51
        +            case

      
        
        52
        +                Maybe.map2 (\access refresh -> { accessToken = access, refreshToken = refresh })

      
        
        53
        +                    flags.accessToken

      
        
        54
        +                    flags.refreshToken

      
        
        55
        +            of

      
        57
        56
                         Just credentials ->

      
        58
        57
                             Auth.User.SignedIn credentials

      
        59
        58
         

      ···
        65
        64
               , appURL = flags.appUrl

      
        66
        65
               }

      
        67
        66
             , Effect.batch

      
        68
        
        -        [ Time.now |> Task.perform Shared.Msg.CheckTokenExpiration |> Effect.sendCmd

      
        69
        
        -        , Time.here |> Task.perform Shared.Msg.GotZone |> Effect.sendCmd

      
        
        67
        +        [ Time.here |> Task.perform Shared.Msg.GotZone |> Effect.sendCmd

      
        
        68
        +        , Time.now |> Task.perform Shared.Msg.CheckTokenExpiration |> Effect.sendCmd

      
        70
        69
                 ]

      
        71
        70
             )

      
        72
        71
         

      ···
        91
        90
                 Shared.Msg.SignedIn credentials ->

      
        92
        91
                     ( { model | user = Auth.User.SignedIn credentials }

      
        93
        92
                     , Effect.batch

      
        94
        
        -                [ Effect.pushRoute

      
        95
        
        -                    { path = Route.Path.Home_

      
        96
        
        -                    , query = Dict.empty

      
        97
        
        -                    , hash = Nothing

      
        98
        
        -                    }

      
        
        93
        +                [ Effect.pushRoute { path = Route.Path.Home_, query = Dict.empty, hash = Nothing }

      
        99
        94
                         , Effect.saveUser credentials.accessToken credentials.refreshToken

      
        100
        95
                         ]

      
        101
        96
                     )