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,4 +1,4 @@

-module Components.Form exposing (ButtonStyle(..), CanBeClicked, button, input, submitButton) +module Components.Form exposing (ButtonStyle(..), CanBeClicked, InputStyle(..), button, input, submitButton) import Html as H exposing (Html) import Html.Attributes as A

@@ -9,44 +9,59 @@

-- INPUT +type InputStyle + = Simple + | Complex + { prefix : String + , helpText : String + } + + input : - -- TODO: add `error : Maybe String`, to show that field is not correct and message { id : String , field : field - , label : String , type_ : String , value : String + , label : String , placeholder : String , required : Bool - , helpText : Maybe String - , prefix : Maybe String , onInput : String -> msg + , style : InputStyle + , error : Maybe String } -> Html msg input opts = + let + style = + case opts.style of + Simple -> + { prefix = H.text "", help = H.text "" } + + Complex complex -> + { prefix = H.span [ A.class "text-gray-500 text-md whitespace-nowrap" ] [ H.text complex.prefix ] + , help = H.p [ A.class "text-xs text-gray-500 mt-1" ] [ H.text complex.helpText ] + } + + error = + case opts.error of + Nothing -> + { element = H.text "", inputAdditionalClasses = "border-gray-300 focus:ring-black " } + + Just err -> + { element = H.p [ A.class "text-red-600 text-xs mt-1" ] [ H.text err ] + , inputAdditionalClasses = " border-red-400 focus:ring-red-500" + } + in H.div [ A.class "space-y-2" ] [ H.label [ A.for opts.id , A.class "block text-sm font-medium text-gray-700" ] [ H.text opts.label ] - , H.div - [ A.class - (if opts.prefix /= Nothing then - "flex items-center" - - else - "" - ) - ] - [ case opts.prefix of - Just prefix -> - H.span [ A.class "text-gray-500 text-md mr-2 whitespace-nowrap" ] [ H.text prefix ] - - Nothing -> - H.text "" + , H.div [ A.class "flex items-center" ] + [ style.prefix , H.input - [ 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" + [ 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) , A.type_ opts.type_ , A.value opts.value , A.id opts.id

@@ -56,12 +71,8 @@ , E.onInput opts.onInput

] [] ] - , case opts.helpText of - Just help -> - H.p [ A.class "text-xs text-gray-500 mt-1" ] [ H.text help ] - - Nothing -> - H.text "" + , error.element + , style.help ]
M web/src/Pages/Auth.elm

@@ -336,46 +336,45 @@ , E.onSubmit UserClickedSubmit

] (case model.formVariant of SignIn -> - [ viewFormInput { field = Email, value = model.email } - , viewFormInput { field = Password, value = model.password } + [ viewFormInput { field = Email, value = model.email, error = validateEmail model.email } + , viewFormInput { field = Password, value = model.password, error = validatePassword model.password } , viewForgotPassword , viewSubmitButton model ] SignUp -> - [ viewFormInput { field = Email, value = model.email } - , viewFormInput { field = Password, value = model.password } - , viewFormInput { field = PasswordAgain, value = model.passwordAgain } + [ viewFormInput { field = Email, value = model.email, error = validateEmail model.email } + , viewFormInput { field = Password, value = model.password, error = validatePassword model.password } + , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = validatePasswords model.password model.passwordAgain } , viewSubmitButton model ] ForgotPassword -> - [ viewFormInput { field = Email, value = model.email } + [ viewFormInput { field = Email, value = model.email, error = validateEmail model.email } , viewSubmitButton model ] - SetNewPassword token -> - [ viewFormInput { field = Password, value = model.password } - , viewFormInput { field = PasswordAgain, value = model.passwordAgain } - , H.input [ A.type_ "hidden", A.value token, A.name "token" ] [] + SetNewPassword _ -> + [ viewFormInput { field = Password, value = model.password, error = validatePassword model.password } + , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = validatePasswords model.password model.passwordAgain } , viewSubmitButton model ] ) -viewFormInput : { field : Field, value : String } -> Html Msg +viewFormInput : { field : Field, value : String, error : Maybe String } -> Html Msg viewFormInput opts = Components.Form.input - { id = (fromFieldToFieldInfo opts.field).label - , field = opts.field + { style = Components.Form.Simple + , id = (fromFieldToFieldInfo opts.field).label + , error = opts.error , label = (fromFieldToFieldInfo opts.field).label , type_ = (fromFieldToFieldInfo opts.field).type_ - , value = opts.value , placeholder = (fromFieldToFieldInfo opts.field).label - , required = True , onInput = UserUpdatedInput opts.field - , helpText = Nothing - , prefix = Nothing + , field = opts.field + , value = opts.value + , required = True }

@@ -406,24 +405,53 @@ isFormDisabled model =

case model.formVariant of SignIn -> model.isSubmittingForm - || String.isEmpty model.email - || String.isEmpty model.password + || (validateEmail model.email /= Nothing) + || (validatePassword model.password /= Nothing) SignUp -> model.isSubmittingForm - || String.isEmpty model.email - || String.isEmpty model.password - || String.isEmpty model.passwordAgain - || (model.password /= model.passwordAgain) + || (validateEmail model.email /= Nothing) + || (validatePassword model.password /= Nothing) + || (validatePasswords model.password model.passwordAgain /= Nothing) ForgotPassword -> - model.isSubmittingForm || String.isEmpty model.email + model.isSubmittingForm || (validateEmail model.email /= Nothing) SetNewPassword _ -> model.isSubmittingForm - || String.isEmpty model.password - || String.isEmpty model.passwordAgain - || (model.password /= model.passwordAgain) + || (validateEmail model.email /= Nothing) + || (validatePassword model.password /= Nothing) + || (validatePasswords model.password model.passwordAgain /= Nothing) + + +validateEmail : String -> Maybe String +validateEmail email = + if + not (String.isEmpty email) + && (not (String.contains "@" email) || not (String.contains "." email)) + then + Just "Please enter a valid email address." + + else + Nothing + + +validatePassword : String -> Maybe String +validatePassword passwd = + if not (String.isEmpty passwd) && String.length passwd < 8 then + Just "Password must be at least 8 characters long." + + else + Nothing + + +validatePasswords : String -> String -> Maybe String +validatePasswords passowrd1 password2 = + if not (String.isEmpty passowrd1) && passowrd1 /= password2 then + Just "Passwords do not match." + + else + Nothing fromVariantToLabel : FormVariant -> String
M web/src/Pages/Home_.elm

@@ -241,7 +241,6 @@

-- VIEW CREATE NOTE --- TODO: validate the form viewCreateNoteForm : Model -> (String -> String) -> Html Msg

@@ -252,29 +251,37 @@ , A.class "space-y-6"

] [ viewTextarea , Components.Form.input - { id = "slug" + { style = + Components.Form.Complex + { prefix = appUrl "" + , helpText = "Leave empty to generate a random slug" + } + , error = validateSlugInput model.slug , field = Slug + , id = "slug" , label = "Custom URL Slug (optional)" - , placeholder = "my-unique-slug" - , type_ = "text" - , helpText = Just "Leave empty to generate a random slug" - , prefix = Just (appUrl "") , onInput = UserUpdatedInput Slug + , placeholder = "my-unique-slug" , required = False + , type_ = "text" , value = Maybe.withDefault "" model.slug } , H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ] [ H.div [ A.class "space-y-6" ] [ Components.Form.input - { id = "password" + { style = + Components.Form.Complex + { prefix = "" + , helpText = "Viewers will need this password to access the paste" + } , field = Password + , id = "password" + , error = Nothing , label = "Password Protection (optional)" - , type_ = "password" + , onInput = UserUpdatedInput Password , placeholder = "Enter password to protect this paste" - , helpText = Just "Viewers will need this password to access the paste" - , prefix = Nothing - , onInput = UserUpdatedInput Password , required = False + , type_ = "password" , value = Maybe.withDefault "" model.password } ]

@@ -287,7 +294,7 @@ , H.div [ A.class "flex justify-end" ]

[ Components.Form.submitButton { text = "Create note" , style = Components.Form.Primary (isFormDisabled model) - , disabled = False + , disabled = isFormDisabled model , class = "" } ]

@@ -356,6 +363,20 @@

isFormDisabled : Model -> Bool isFormDisabled model = String.isEmpty model.content + || (validateSlugInput model.slug /= Nothing) + + +validateSlugInput : Maybe String -> Maybe String +validateSlugInput slug = + let + value = + Maybe.withDefault "" slug + in + if not (String.isEmpty value) && String.contains " " value then + Just "Slug cannot contain spaces." + + else + Nothing fromFieldToName : Field -> String
M web/src/Shared.elm

@@ -47,13 +47,12 @@ let

flags = flagsResult |> Result.withDefault { accessToken = Nothing, refreshToken = Nothing, appUrl = "" } - maybeCredentials = - Maybe.map2 (\access refresh -> { accessToken = access, refreshToken = refresh }) - flags.accessToken - flags.refreshToken - user = - case maybeCredentials of + case + Maybe.map2 (\access refresh -> { accessToken = access, refreshToken = refresh }) + flags.accessToken + flags.refreshToken + of Just credentials -> Auth.User.SignedIn credentials

@@ -65,8 +64,8 @@ , timeZone = Time.utc

, appURL = flags.appUrl } , Effect.batch - [ Time.now |> Task.perform Shared.Msg.CheckTokenExpiration |> Effect.sendCmd - , Time.here |> Task.perform Shared.Msg.GotZone |> Effect.sendCmd + [ Time.here |> Task.perform Shared.Msg.GotZone |> Effect.sendCmd + , Time.now |> Task.perform Shared.Msg.CheckTokenExpiration |> Effect.sendCmd ] )

@@ -91,11 +90,7 @@

Shared.Msg.SignedIn credentials -> ( { model | user = Auth.User.SignedIn credentials } , Effect.batch - [ Effect.pushRoute - { path = Route.Path.Home_ - , query = Dict.empty - , hash = Nothing - } + [ Effect.pushRoute { path = Route.Path.Home_, query = Dict.empty, hash = Nothing } , Effect.saveUser credentials.accessToken credentials.refreshToken ] )