all repos

onasty @ 7ff621d

a one-time notes service
11 files changed, 452 insertions(+), 167 deletions(-)
feat(web): add account settings (#190)

* web: /profile/me to /profile

* web: update me data

* web: setup profile page boilerplate

* web: profile, add sidebar

* web: implement overview page

* web: implement password change

* web: refactor profile

* web: implement change email

* web: add the logic and make it work with api

* refactor(web): reuse common validators

* refactor: store only one email, and use it

* feat(web): add validators

* refactor(web): remove last todo comment

* refactor(web): correct buttons styles; show user feedback if operating was successfully

* refactor(web): reset api error on page change
Author: Olexandr Smirnov ss2316544@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-08-22 19:56:15 +0300
Parent: 234f764
D

@@ -1,17 +0,0 @@

-module Api.Me exposing (get) - -import Api -import Data.Me as Me exposing (Me) -import Effect exposing (Effect) -import Http - - -get : { onResponse : Result Api.Error Me -> msg } -> Effect msg -get options = - Effect.sendApiRequest - { endpoint = "/api/v1/me" - , method = "GET" - , body = Http.emptyBody - , onResponse = options.onResponse - , decoder = Me.decode - }
A web/src/Api/Profile.elm

@@ -0,0 +1,46 @@

+module Api.Profile exposing (changePassword, me, requestEmailChange) + +import Api +import Data.Me as Me exposing (Me) +import Effect exposing (Effect) +import Http +import Json.Decode as Decode +import Json.Encode as E + + +me : { onResponse : Result Api.Error Me -> msg } -> Effect msg +me options = + Effect.sendApiRequest + { endpoint = "/api/v1/me" + , method = "GET" + , body = Http.emptyBody + , onResponse = options.onResponse + , decoder = Me.decode + } + + +requestEmailChange : { onResponse : Result Api.Error () -> msg, newEmail : String } -> Effect msg +requestEmailChange { onResponse, newEmail } = + Effect.sendApiRequest + { endpoint = "/api/v1/auth/change-email" + , method = "POST" + , body = E.object [ ( "new_email", E.string newEmail ) ] |> Http.jsonBody + , onResponse = onResponse + , decoder = Decode.succeed () + } + + +changePassword : { onResponse : Result Api.Error () -> msg, currentPassword : String, newPassword : String } -> Effect msg +changePassword { onResponse, currentPassword, newPassword } = + Effect.sendApiRequest + { endpoint = "/api/v1/auth/change-password" + , method = "POST" + , body = + Http.jsonBody <| + E.object + [ ( "current_password", E.string currentPassword ) + , ( "new_password", E.string newPassword ) + ] + , onResponse = onResponse + , decoder = Decode.succeed () + }
M web/src/Components/Box.elm

@@ -1,4 +1,4 @@

-module Components.Box exposing (error, success, successBox) +module Components.Box exposing (error, success, successBox, successText) import Html as H exposing (Html) import Html.Attributes as A

@@ -16,6 +16,11 @@ successBox

[ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text opts.header ] , H.p [ A.class "text-green-800 text-sm" ] [ H.text opts.body ] ] + + +successText : String -> Html msg +successText text = + successBox [ H.p [ A.class "text-green-800 text-sm" ] [ H.text text ] ] successBox : List (Html msg) -> Html msg
M web/src/Components/Form.elm

@@ -86,6 +86,7 @@

type ButtonStyle = Primary CanBeClicked + | PrimaryReverse CanBeClicked | Secondary CanBeClicked | SecondaryDisabled CanBeClicked | SecondaryDanger

@@ -120,6 +121,12 @@ getButtonClasses canBeClicked

appendClasses "px-6 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors" "px-6 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" + + PrimaryReverse canBeClicked -> + getButtonClasses canBeClicked + appendClasses + "items-center gap-3 px-3 py-2 text-left rounded-md transition-colors bg-black text-white" + "items-center gap-3 px-3 py-2 text-left rounded-md transition-colors text-gray-700 hover:bg-gray-100" SecondaryDanger -> "text-gray-600 hover:text-red-600 transition-colors"
M web/src/Data/Me.elm

@@ -8,11 +8,15 @@

type alias Me = { email : String , createdAt : Posix + , lastLoginAt : Posix + , notesCreated : Int } decode : Decoder Me decode = - Decode.map2 Me + Decode.map4 Me (Decode.field "email" Decode.string) (Decode.field "created_at" Iso8601.decoder) + (Decode.field "last_login_at" Iso8601.decoder) + (Decode.field "notes_created" Decode.int)
M web/src/Layouts/Header.elm

@@ -100,7 +100,7 @@ [ H.text text ]

in case user of Auth.User.SignedIn _ -> - [ viewLink "Profile" Route.Path.Profile_Me + [ viewLink "Profile" Route.Path.Profile , Components.Form.button { text = "Logout" , onClick = UserClickedLogout
M web/src/Pages/Auth.elm

@@ -18,6 +18,7 @@ import Route exposing (Route)

import Route.Path import Shared import Time exposing (Posix) +import Validators import View exposing (View)

@@ -336,27 +337,27 @@ , E.onSubmit UserClickedSubmit

] (case model.formVariant of SignIn -> - [ viewFormInput { field = Email, value = model.email, error = validateEmail model.email } - , viewFormInput { field = Password, value = model.password, error = validatePassword model.password } + [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email } + , viewFormInput { field = Password, value = model.password, error = Validators.password model.password } , viewForgotPassword , viewSubmitButton model ] SignUp -> - [ 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 } + [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email } + , viewFormInput { field = Password, value = model.password, error = Validators.password model.password } + , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = Validators.passwords model.password model.passwordAgain } , viewSubmitButton model ] ForgotPassword -> - [ viewFormInput { field = Email, value = model.email, error = validateEmail model.email } + [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email } , viewSubmitButton model ] SetNewPassword _ -> - [ viewFormInput { field = Password, value = model.password, error = validatePassword model.password } - , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = validatePasswords model.password model.passwordAgain } + [ viewFormInput { field = Password, value = model.password, error = Validators.password model.password } + , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = Validators.passwords model.password model.passwordAgain } , viewSubmitButton model ] )

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

case model.formVariant of SignIn -> model.isSubmittingForm - || (validateEmail model.email /= Nothing) - || (validatePassword model.password /= Nothing) + || (Validators.email model.email /= Nothing) + || (Validators.password model.password /= Nothing) SignUp -> model.isSubmittingForm - || (validateEmail model.email /= Nothing) - || (validatePassword model.password /= Nothing) - || (validatePasswords model.password model.passwordAgain /= Nothing) + || (Validators.email model.email /= Nothing) + || (Validators.password model.password /= Nothing) + || (Validators.passwords model.password model.passwordAgain /= Nothing) ForgotPassword -> - model.isSubmittingForm || (validateEmail model.email /= Nothing) + model.isSubmittingForm || (Validators.email model.email /= Nothing) SetNewPassword _ -> model.isSubmittingForm - || (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 + || (Validators.email model.email /= Nothing) + || (Validators.password model.password /= Nothing) + || (Validators.passwords model.password model.passwordAgain /= Nothing) fromVariantToLabel : FormVariant -> String
A web/src/Pages/Profile.elm

@@ -0,0 +1,335 @@

+module Pages.Profile exposing (Model, Msg, ViewVariant, page) + +import Api +import Api.Profile +import Auth +import Components.Box +import Components.Form +import Components.Utils +import Data.Me exposing (Me) +import Effect exposing (Effect) +import Html as H exposing (Html) +import Html.Attributes as A +import Html.Events +import Layouts +import Page exposing (Page) +import Route exposing (Route) +import Shared +import Time.Format +import Validators +import View exposing (View) + + +page : Auth.User -> Shared.Model -> Route () -> Page Model Msg +page _ shared _ = + Page.new + { init = init shared + , update = update + , subscriptions = subscriptions + , view = view shared + } + |> Page.withLayout (\_ -> Layouts.Header {}) + + + +-- INIT + + +type alias Model = + { view : ViewVariant + , me : Api.Response Me + , password : { current : String, new : String, confirm : String } + , email : String + , apiError : Maybe Api.Error + , isFormSentSuccessfully : Bool + } + + +init : Shared.Model -> () -> ( Model, Effect Msg ) +init _ () = + ( { view = Overview + , me = Api.Loading + , password = { current = "", new = "", confirm = "" } + , email = "" + , apiError = Nothing + , isFormSentSuccessfully = False + } + , Api.Profile.me { onResponse = ApiMeResponded } + ) + + + +-- UPDATE + + +type ViewVariant + = Overview + | Password + | Email + + +type Field + = PasswordCurrent + | PasswordNew + | PasswordConfirm + | EmailNew + + +type Msg + = UserChangedView ViewVariant + | UserClickedSubmit + | UserChangedField Field String + | ApiMeResponded (Result Api.Error Me) + | ApiChangePasswordResponsed (Result Api.Error ()) + | ApiRequestEmailChangeResponsed (Result Api.Error ()) + + +update : Msg -> Model -> ( Model, Effect Msg ) +update msg model = + case msg of + UserChangedView variant -> + ( { model | view = variant, isFormSentSuccessfully = False, apiError = Nothing }, Effect.none ) + + UserChangedField PasswordCurrent value -> + ( { model | password = { current = value, new = model.password.new, confirm = model.password.confirm } }, Effect.none ) + + UserChangedField PasswordNew value -> + ( { model | password = { current = model.password.current, new = value, confirm = model.password.confirm } }, Effect.none ) + + UserChangedField PasswordConfirm value -> + ( { model | password = { current = model.password.current, new = model.password.new, confirm = value } }, Effect.none ) + + UserChangedField EmailNew value -> + ( { model | email = value }, Effect.none ) + + UserClickedSubmit -> + case model.view of + Password -> + ( model + , Api.Profile.changePassword + { onResponse = ApiChangePasswordResponsed + , currentPassword = model.password.current + , newPassword = model.password.new + } + ) + + Email -> + ( model + , Api.Profile.requestEmailChange + { onResponse = ApiRequestEmailChangeResponsed + , newEmail = model.email + } + ) + + _ -> + ( model, Effect.none ) + + ApiMeResponded (Ok userData) -> + ( { model | me = Api.Success userData }, Effect.none ) + + ApiMeResponded (Err error) -> + ( { model | me = Api.Failure error }, Effect.none ) + + ApiChangePasswordResponsed (Ok ()) -> + ( { model | isFormSentSuccessfully = True }, Effect.none ) + + ApiChangePasswordResponsed (Err err) -> + ( { model | apiError = Just err }, Effect.none ) + + ApiRequestEmailChangeResponsed (Ok ()) -> + ( { model | isFormSentSuccessfully = True }, Effect.none ) + + ApiRequestEmailChangeResponsed (Err err) -> + ( { model | apiError = Just err }, Effect.none ) + + +subscriptions : Model -> Sub Msg +subscriptions _ = + Sub.none + + + +-- VIEW + + +view : Shared.Model -> Model -> View Msg +view shared model = + { title = "Profile" + , body = + [ H.div [ A.class "w-full p-6 max-w-4xl mx-auto" ] + [ H.div [ A.class "rounded-lg border border-gray-200 shadow-sm" ] + [ H.div [ A.class "p-6 border-b border-gray-200" ] + [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e)) + , H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text "Account Settings" ] + , H.p [ A.class "text-gray-600" ] [ H.text "Manage your account preferences and security settings" ] + ] + , H.div [ A.class "flex" ] + [ viewNavigationSidebar model + , H.div [ A.class "flex-1 p-6" ] + [ case model.me of + Api.Success me -> + case model.view of + Overview -> + viewOverview shared me + + Password -> + viewPassword model.password (isFormDisabled model) model.isFormSentSuccessfully + + Email -> + viewEmail me model.email (isFormDisabled model) model.isFormSentSuccessfully + + Api.Loading -> + H.text "Loading..." + + Api.Failure err -> + H.text ("ERROR: " ++ Api.errorMessage err) + ] + ] + ] + ] + ] + } + + +isFormDisabled : Model -> Bool +isFormDisabled model = + case model.view of + Overview -> + True + + Password -> + (Validators.password model.password.new /= Nothing) + || (Validators.passwords model.password.new model.password.confirm /= Nothing) + + Email -> + Validators.email model.email /= Nothing + + +viewNavigationSidebar : Model -> Html Msg +viewNavigationSidebar model = + let + button variant text = + Components.Form.button + { text = text + , onClick = UserChangedView variant + , disabled = model.view == variant + , style = Components.Form.PrimaryReverse (model.view == variant) + } + in + H.div [ A.class "w-64 border-r border-gray-200 p-6" ] + [ H.nav [ A.class "[&>*]:w-full space-y-2" ] + [ button Overview "Overview" + , button Password "Password" + , button Email "Email" + ] + ] + + +viewOverview : Shared.Model -> Me -> Html Msg +viewOverview shared me = + let + infoBox title text = + H.div [ A.class "bg-gray-50 rounded-lg p-4" ] + [ H.div [ A.class "flex items-center gap-3 mb-2" ] + [ H.h3 [ A.class "font-medium text-gray-900" ] [ H.text title ] ] + , H.p [ A.class "text-gray-700" ] [ H.text text ] + ] + in + viewWrapper + { title = "Account Overview" + , body = + H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ] + [ infoBox "Email Address" me.email + , infoBox "Member Since" (Time.Format.toString shared.timeZone me.createdAt) + , infoBox "Last Login" (Time.Format.toString shared.timeZone me.lastLoginAt) + , infoBox "Total Notes Created" (String.fromInt me.notesCreated) + ] + } + + +viewPassword : { current : String, new : String, confirm : String } -> Bool -> Bool -> Html Msg +viewPassword password isButtonDisabled isFormSentSuccessfully = + let + input : { label : String, field : Field, value : String, error : Maybe String } -> Html Msg + input { label, field, value, error } = + Components.Form.input + { label = label + , id = label + , field = field + , onInput = UserChangedField field + , placeholder = "" + , value = value + , required = True + , type_ = "password" + , style = Components.Form.Simple + , error = error + } + in + viewWrapper + { title = "Change Password" + , body = + H.form + [ A.class "space-y-4 max-w-md" + , Html.Events.onSubmit UserClickedSubmit + ] + [ Components.Utils.viewIf isFormSentSuccessfully (Components.Box.successText "Password updated successfully!") + , input { label = "Current Password", field = PasswordCurrent, value = password.current, error = Nothing } + , input { label = "New Password", field = PasswordNew, value = password.new, error = Validators.password password.new } + , input { label = "Confirm New Password", field = PasswordConfirm, value = password.confirm, error = Validators.passwords password.new password.confirm } + , Components.Form.submitButton + { disabled = isButtonDisabled + , text = "Change Password" + , style = Components.Form.Primary isButtonDisabled + , class = "" + } + ] + } + + +viewEmail : Me -> String -> Bool -> Bool -> Html Msg +viewEmail me email isButtonDisabled isFormSentSuccessfully = + viewWrapper + { title = "Change Email Address" + , body = + H.form + [ A.class "space-y-4 max-w-md" + , Html.Events.onSubmit UserClickedSubmit + ] + [ H.div [ A.class "mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md" ] + [ H.h3 [ A.class "font-medium mb-1" ] [ H.text "Note:" ] + , H.p [] [ H.text "A confirmation email will be sent to your current email address. You must confirm the change by clicking the link in that email." ] + , H.p [ A.class "mt-2 text-blue-800 text-sm" ] + [ H.span [ A.class "font-medium" ] [ H.text ("Current email: " ++ me.email) ] + ] + ] + , Components.Utils.viewIf isFormSentSuccessfully (Components.Box.successText "Email updated successfully! Please check your new email for verification.") + , Components.Form.input + { style = Components.Form.Simple + , id = "new-email" + , type_ = "email" + , field = EmailNew + , label = "New Email Address" + , value = email + , placeholder = "Enter your new email address" + , onInput = UserChangedField EmailNew + , error = Validators.email email + , required = True + } + , Components.Form.submitButton + { disabled = isButtonDisabled + , text = "Update Email" + , style = Components.Form.Primary isButtonDisabled + , class = "" + } + ] + } + + +viewWrapper : { title : String, body : Html Msg } -> Html Msg +viewWrapper { title, body } = + H.div [ A.class "space-y-6" ] + [ H.div [] + [ H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-4" ] [ H.text title ] + , body + ] + ]
D

@@ -1,99 +0,0 @@

-module Pages.Profile.Me exposing (Model, Msg, page) - -import Api -import Api.Me -import Auth -import Data.Me exposing (Me) -import Effect exposing (Effect) -import Html exposing (Html) -import Layouts -import Page exposing (Page) -import Route exposing (Route) -import Shared -import Time.Format as T -import View exposing (View) - - -page : Auth.User -> Shared.Model -> Route () -> Page Model Msg -page _ shared _ = - Page.new - { init = init shared - , update = update - , subscriptions = subscriptions - , view = view shared - } - |> Page.withLayout (\_ -> Layouts.Header {}) - - - --- INIT - - -type alias Model = - { me : Api.Response Me } - - -init : Shared.Model -> () -> ( Model, Effect Msg ) -init _ () = - ( { me = Api.Loading } - , Api.Me.get { onResponse = ApiMeResponded } - ) - - - --- UPDATE - - -type Msg - = ApiMeResponded (Result Api.Error Me) - - -update : Msg -> Model -> ( Model, Effect Msg ) -update msg model = - case msg of - ApiMeResponded (Ok userData) -> - ( { model | me = Api.Success userData }, Effect.none ) - - ApiMeResponded (Err error) -> - ( { model | me = Api.Failure error }, Effect.none ) - - - --- SUBSCRIPTIONS - - -subscriptions : Model -> Sub Msg -subscriptions _ = - Sub.none - - - --- VIEW - - -view : Shared.Model -> Model -> View Msg -view shared model = - { title = "Profile" - , body = [ viewProfileContent shared model.me ] - } - - -viewProfileContent : Shared.Model -> Api.Response Me -> Html Msg -viewProfileContent shared userResponse = - case userResponse of - Api.Loading -> - Html.text "Loading..." - - Api.Success user -> - viewUserDetails shared user - - Api.Failure err -> - Html.text (Api.errorMessage err) - - -viewUserDetails : Shared.Model -> Me -> Html Msg -viewUserDetails shared me = - Html.div [] - [ Html.p [] [ Html.text ("Email: " ++ me.email) ] - , Html.p [] [ Html.text ("Joined: " ++ T.toString shared.timeZone me.createdAt) ] - ]
A web/src/Validators.elm

@@ -0,0 +1,31 @@

+module Validators exposing (email, password, passwords) + + +email : String -> Maybe String +email inp = + if + not (String.isEmpty inp) + && (not (String.contains "@" inp) && not (String.contains "." inp)) + then + Just "Please enter a valid email address." + + else + Nothing + + +password : String -> Maybe String +password passwd = + if not (String.isEmpty passwd) && String.length passwd < 8 then + Just "Password must be at least 8 characters long." + + else + Nothing + + +passwords : String -> String -> Maybe String +passwords passowrd1 password2 = + if not (String.isEmpty passowrd1) && passowrd1 /= password2 then + Just "Passwords do not match." + + else + Nothing
M web/tests/UnitTests/Data/Me.elm

@@ -14,7 +14,9 @@ \_ ->

""" { "email": "admin@onasty.local", - "created_at": "2025-06-06T19:44:17.370068Z" + "created_at": "2025-06-06T19:44:17.370068Z", + "last_login_at": "2025-07-06T17:15:23.380068Z", + "notes_created": 42 } """ |> Json.decodeString Data.Me.decode