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 web/src/Api/Me.elm
···
        1
        
        -module Api.Me exposing (get)

      
        2
        
        -

      
        3
        
        -import Api

      
        4
        
        -import Data.Me as Me exposing (Me)

      
        5
        
        -import Effect exposing (Effect)

      
        6
        
        -import Http

      
        7
        
        -

      
        8
        
        -

      
        9
        
        -get : { onResponse : Result Api.Error Me -> msg } -> Effect msg

      
        10
        
        -get options =

      
        11
        
        -    Effect.sendApiRequest

      
        12
        
        -        { endpoint = "/api/v1/me"

      
        13
        
        -        , method = "GET"

      
        14
        
        -        , body = Http.emptyBody

      
        15
        
        -        , onResponse = options.onResponse

      
        16
        
        -        , decoder = Me.decode

      
        17
        
        -        }

      
A web/src/Api/Profile.elm
···
        
        1
        +module Api.Profile exposing (changePassword, me, requestEmailChange)

      
        
        2
        +

      
        
        3
        +import Api

      
        
        4
        +import Data.Me as Me exposing (Me)

      
        
        5
        +import Effect exposing (Effect)

      
        
        6
        +import Http

      
        
        7
        +import Json.Decode as Decode

      
        
        8
        +import Json.Encode as E

      
        
        9
        +

      
        
        10
        +

      
        
        11
        +me : { onResponse : Result Api.Error Me -> msg } -> Effect msg

      
        
        12
        +me options =

      
        
        13
        +    Effect.sendApiRequest

      
        
        14
        +        { endpoint = "/api/v1/me"

      
        
        15
        +        , method = "GET"

      
        
        16
        +        , body = Http.emptyBody

      
        
        17
        +        , onResponse = options.onResponse

      
        
        18
        +        , decoder = Me.decode

      
        
        19
        +        }

      
        
        20
        +

      
        
        21
        +

      
        
        22
        +requestEmailChange : { onResponse : Result Api.Error () -> msg, newEmail : String } -> Effect msg

      
        
        23
        +requestEmailChange { onResponse, newEmail } =

      
        
        24
        +    Effect.sendApiRequest

      
        
        25
        +        { endpoint = "/api/v1/auth/change-email"

      
        
        26
        +        , method = "POST"

      
        
        27
        +        , body = E.object [ ( "new_email", E.string newEmail ) ] |> Http.jsonBody

      
        
        28
        +        , onResponse = onResponse

      
        
        29
        +        , decoder = Decode.succeed ()

      
        
        30
        +        }

      
        
        31
        +

      
        
        32
        +

      
        
        33
        +changePassword : { onResponse : Result Api.Error () -> msg, currentPassword : String, newPassword : String } -> Effect msg

      
        
        34
        +changePassword { onResponse, currentPassword, newPassword } =

      
        
        35
        +    Effect.sendApiRequest

      
        
        36
        +        { endpoint = "/api/v1/auth/change-password"

      
        
        37
        +        , method = "POST"

      
        
        38
        +        , body =

      
        
        39
        +            Http.jsonBody <|

      
        
        40
        +                E.object

      
        
        41
        +                    [ ( "current_password", E.string currentPassword )

      
        
        42
        +                    , ( "new_password", E.string newPassword )

      
        
        43
        +                    ]

      
        
        44
        +        , onResponse = onResponse

      
        
        45
        +        , decoder = Decode.succeed ()

      
        
        46
        +        }

      
M web/src/Components/Box.elm
···
        1
        
        -module Components.Box exposing (error, success, successBox)

      
        
        1
        +module Components.Box exposing (error, success, successBox, successText)

      
        2
        2
         

      
        3
        3
         import Html as H exposing (Html)

      
        4
        4
         import Html.Attributes as A

      ···
        16
        16
                 [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text opts.header ]

      
        17
        17
                 , H.p [ A.class "text-green-800 text-sm" ] [ H.text opts.body ]

      
        18
        18
                 ]

      
        
        19
        +

      
        
        20
        +

      
        
        21
        +successText : String -> Html msg

      
        
        22
        +successText text =

      
        
        23
        +    successBox [ H.p [ A.class "text-green-800 text-sm" ] [ H.text text ] ]

      
        19
        24
         

      
        20
        25
         

      
        21
        26
         successBox : List (Html msg) -> Html msg

      
M web/src/Components/Form.elm
···
        86
        86
         

      
        87
        87
         type ButtonStyle

      
        88
        88
             = Primary CanBeClicked

      
        
        89
        +    | PrimaryReverse CanBeClicked

      
        89
        90
             | Secondary CanBeClicked

      
        90
        91
             | SecondaryDisabled CanBeClicked

      
        91
        92
             | SecondaryDanger

      ···
        120
        121
                         appendClasses

      
        121
        122
                         "px-6 py-2 bg-gray-300 text-gray-500 rounded-md cursor-not-allowed transition-colors"

      
        122
        123
                         "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"

      
        
        124
        +

      
        
        125
        +        PrimaryReverse canBeClicked ->

      
        
        126
        +            getButtonClasses canBeClicked

      
        
        127
        +                appendClasses

      
        
        128
        +                "items-center gap-3 px-3 py-2 text-left rounded-md transition-colors bg-black text-white"

      
        
        129
        +                "items-center gap-3 px-3 py-2 text-left rounded-md transition-colors text-gray-700 hover:bg-gray-100"

      
        123
        130
         

      
        124
        131
                 SecondaryDanger ->

      
        125
        132
                     "text-gray-600 hover:text-red-600 transition-colors"

      
M web/src/Data/Me.elm
···
        8
        8
         type alias Me =

      
        9
        9
             { email : String

      
        10
        10
             , createdAt : Posix

      
        
        11
        +    , lastLoginAt : Posix

      
        
        12
        +    , notesCreated : Int

      
        11
        13
             }

      
        12
        14
         

      
        13
        15
         

      
        14
        16
         decode : Decoder Me

      
        15
        17
         decode =

      
        16
        
        -    Decode.map2 Me

      
        
        18
        +    Decode.map4 Me

      
        17
        19
                 (Decode.field "email" Decode.string)

      
        18
        20
                 (Decode.field "created_at" Iso8601.decoder)

      
        
        21
        +        (Decode.field "last_login_at" Iso8601.decoder)

      
        
        22
        +        (Decode.field "notes_created" Decode.int)

      
M web/src/Layouts/Header.elm
···
        100
        100
             in

      
        101
        101
             case user of

      
        102
        102
                 Auth.User.SignedIn _ ->

      
        103
        
        -            [ viewLink "Profile" Route.Path.Profile_Me

      
        
        103
        +            [ viewLink "Profile" Route.Path.Profile

      
        104
        104
                     , Components.Form.button

      
        105
        105
                         { text = "Logout"

      
        106
        106
                         , onClick = UserClickedLogout

      
M web/src/Pages/Auth.elm
···
        18
        18
         import Route.Path

      
        19
        19
         import Shared

      
        20
        20
         import Time exposing (Posix)

      
        
        21
        +import Validators

      
        21
        22
         import View exposing (View)

      
        22
        23
         

      
        23
        24
         

      ···
        336
        337
                 ]

      
        337
        338
                 (case model.formVariant of

      
        338
        339
                     SignIn ->

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

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

      
        
        340
        +                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }

      
        
        341
        +                , viewFormInput { field = Password, value = model.password, error = Validators.password model.password }

      
        341
        342
                         , viewForgotPassword

      
        342
        343
                         , viewSubmitButton model

      
        343
        344
                         ]

      
        344
        345
         

      
        345
        346
                     SignUp ->

      
        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 }

      
        
        347
        +                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }

      
        
        348
        +                , viewFormInput { field = Password, value = model.password, error = Validators.password model.password }

      
        
        349
        +                , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = Validators.passwords model.password model.passwordAgain }

      
        349
        350
                         , viewSubmitButton model

      
        350
        351
                         ]

      
        351
        352
         

      
        352
        353
                     ForgotPassword ->

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

      
        
        354
        +                [ viewFormInput { field = Email, value = model.email, error = Validators.email model.email }

      
        354
        355
                         , viewSubmitButton model

      
        355
        356
                         ]

      
        356
        357
         

      
        357
        358
                     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 }

      
        
        359
        +                [ viewFormInput { field = Password, value = model.password, error = Validators.password model.password }

      
        
        360
        +                , viewFormInput { field = PasswordAgain, value = model.passwordAgain, error = Validators.passwords model.password model.passwordAgain }

      
        360
        361
                         , viewSubmitButton model

      
        361
        362
                         ]

      
        362
        363
                 )

      ···
        405
        406
             case model.formVariant of

      
        406
        407
                 SignIn ->

      
        407
        408
                     model.isSubmittingForm

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

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

      
        
        409
        +                || (Validators.email model.email /= Nothing)

      
        
        410
        +                || (Validators.password model.password /= Nothing)

      
        410
        411
         

      
        411
        412
                 SignUp ->

      
        412
        413
                     model.isSubmittingForm

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

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

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

      
        
        414
        +                || (Validators.email model.email /= Nothing)

      
        
        415
        +                || (Validators.password model.password /= Nothing)

      
        
        416
        +                || (Validators.passwords model.password model.passwordAgain /= Nothing)

      
        416
        417
         

      
        417
        418
                 ForgotPassword ->

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

      
        
        419
        +            model.isSubmittingForm || (Validators.email model.email /= Nothing)

      
        419
        420
         

      
        420
        421
                 SetNewPassword _ ->

      
        421
        422
                     model.isSubmittingForm

      
        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

      
        
        423
        +                || (Validators.email model.email /= Nothing)

      
        
        424
        +                || (Validators.password model.password /= Nothing)

      
        
        425
        +                || (Validators.passwords model.password model.passwordAgain /= Nothing)

      
        455
        426
         

      
        456
        427
         

      
        457
        428
         fromVariantToLabel : FormVariant -> String

      
A web/src/Pages/Profile.elm
···
        
        1
        +module Pages.Profile exposing (Model, Msg, ViewVariant, page)

      
        
        2
        +

      
        
        3
        +import Api

      
        
        4
        +import Api.Profile

      
        
        5
        +import Auth

      
        
        6
        +import Components.Box

      
        
        7
        +import Components.Form

      
        
        8
        +import Components.Utils

      
        
        9
        +import Data.Me exposing (Me)

      
        
        10
        +import Effect exposing (Effect)

      
        
        11
        +import Html as H exposing (Html)

      
        
        12
        +import Html.Attributes as A

      
        
        13
        +import Html.Events

      
        
        14
        +import Layouts

      
        
        15
        +import Page exposing (Page)

      
        
        16
        +import Route exposing (Route)

      
        
        17
        +import Shared

      
        
        18
        +import Time.Format

      
        
        19
        +import Validators

      
        
        20
        +import View exposing (View)

      
        
        21
        +

      
        
        22
        +

      
        
        23
        +page : Auth.User -> 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 shared

      
        
        30
        +        }

      
        
        31
        +        |> Page.withLayout (\_ -> Layouts.Header {})

      
        
        32
        +

      
        
        33
        +

      
        
        34
        +

      
        
        35
        +-- INIT

      
        
        36
        +

      
        
        37
        +

      
        
        38
        +type alias Model =

      
        
        39
        +    { view : ViewVariant

      
        
        40
        +    , me : Api.Response Me

      
        
        41
        +    , password : { current : String, new : String, confirm : String }

      
        
        42
        +    , email : String

      
        
        43
        +    , apiError : Maybe Api.Error

      
        
        44
        +    , isFormSentSuccessfully : Bool

      
        
        45
        +    }

      
        
        46
        +

      
        
        47
        +

      
        
        48
        +init : Shared.Model -> () -> ( Model, Effect Msg )

      
        
        49
        +init _ () =

      
        
        50
        +    ( { view = Overview

      
        
        51
        +      , me = Api.Loading

      
        
        52
        +      , password = { current = "", new = "", confirm = "" }

      
        
        53
        +      , email = ""

      
        
        54
        +      , apiError = Nothing

      
        
        55
        +      , isFormSentSuccessfully = False

      
        
        56
        +      }

      
        
        57
        +    , Api.Profile.me { onResponse = ApiMeResponded }

      
        
        58
        +    )

      
        
        59
        +

      
        
        60
        +

      
        
        61
        +

      
        
        62
        +-- UPDATE

      
        
        63
        +

      
        
        64
        +

      
        
        65
        +type ViewVariant

      
        
        66
        +    = Overview

      
        
        67
        +    | Password

      
        
        68
        +    | Email

      
        
        69
        +

      
        
        70
        +

      
        
        71
        +type Field

      
        
        72
        +    = PasswordCurrent

      
        
        73
        +    | PasswordNew

      
        
        74
        +    | PasswordConfirm

      
        
        75
        +    | EmailNew

      
        
        76
        +

      
        
        77
        +

      
        
        78
        +type Msg

      
        
        79
        +    = UserChangedView ViewVariant

      
        
        80
        +    | UserClickedSubmit

      
        
        81
        +    | UserChangedField Field String

      
        
        82
        +    | ApiMeResponded (Result Api.Error Me)

      
        
        83
        +    | ApiChangePasswordResponsed (Result Api.Error ())

      
        
        84
        +    | ApiRequestEmailChangeResponsed (Result Api.Error ())

      
        
        85
        +

      
        
        86
        +

      
        
        87
        +update : Msg -> Model -> ( Model, Effect Msg )

      
        
        88
        +update msg model =

      
        
        89
        +    case msg of

      
        
        90
        +        UserChangedView variant ->

      
        
        91
        +            ( { model | view = variant, isFormSentSuccessfully = False, apiError = Nothing }, Effect.none )

      
        
        92
        +

      
        
        93
        +        UserChangedField PasswordCurrent value ->

      
        
        94
        +            ( { model | password = { current = value, new = model.password.new, confirm = model.password.confirm } }, Effect.none )

      
        
        95
        +

      
        
        96
        +        UserChangedField PasswordNew value ->

      
        
        97
        +            ( { model | password = { current = model.password.current, new = value, confirm = model.password.confirm } }, Effect.none )

      
        
        98
        +

      
        
        99
        +        UserChangedField PasswordConfirm value ->

      
        
        100
        +            ( { model | password = { current = model.password.current, new = model.password.new, confirm = value } }, Effect.none )

      
        
        101
        +

      
        
        102
        +        UserChangedField EmailNew value ->

      
        
        103
        +            ( { model | email = value }, Effect.none )

      
        
        104
        +

      
        
        105
        +        UserClickedSubmit ->

      
        
        106
        +            case model.view of

      
        
        107
        +                Password ->

      
        
        108
        +                    ( model

      
        
        109
        +                    , Api.Profile.changePassword

      
        
        110
        +                        { onResponse = ApiChangePasswordResponsed

      
        
        111
        +                        , currentPassword = model.password.current

      
        
        112
        +                        , newPassword = model.password.new

      
        
        113
        +                        }

      
        
        114
        +                    )

      
        
        115
        +

      
        
        116
        +                Email ->

      
        
        117
        +                    ( model

      
        
        118
        +                    , Api.Profile.requestEmailChange

      
        
        119
        +                        { onResponse = ApiRequestEmailChangeResponsed

      
        
        120
        +                        , newEmail = model.email

      
        
        121
        +                        }

      
        
        122
        +                    )

      
        
        123
        +

      
        
        124
        +                _ ->

      
        
        125
        +                    ( model, Effect.none )

      
        
        126
        +

      
        
        127
        +        ApiMeResponded (Ok userData) ->

      
        
        128
        +            ( { model | me = Api.Success userData }, Effect.none )

      
        
        129
        +

      
        
        130
        +        ApiMeResponded (Err error) ->

      
        
        131
        +            ( { model | me = Api.Failure error }, Effect.none )

      
        
        132
        +

      
        
        133
        +        ApiChangePasswordResponsed (Ok ()) ->

      
        
        134
        +            ( { model | isFormSentSuccessfully = True }, Effect.none )

      
        
        135
        +

      
        
        136
        +        ApiChangePasswordResponsed (Err err) ->

      
        
        137
        +            ( { model | apiError = Just err }, Effect.none )

      
        
        138
        +

      
        
        139
        +        ApiRequestEmailChangeResponsed (Ok ()) ->

      
        
        140
        +            ( { model | isFormSentSuccessfully = True }, Effect.none )

      
        
        141
        +

      
        
        142
        +        ApiRequestEmailChangeResponsed (Err err) ->

      
        
        143
        +            ( { model | apiError = Just err }, Effect.none )

      
        
        144
        +

      
        
        145
        +

      
        
        146
        +subscriptions : Model -> Sub Msg

      
        
        147
        +subscriptions _ =

      
        
        148
        +    Sub.none

      
        
        149
        +

      
        
        150
        +

      
        
        151
        +

      
        
        152
        +-- VIEW

      
        
        153
        +

      
        
        154
        +

      
        
        155
        +view : Shared.Model -> Model -> View Msg

      
        
        156
        +view shared model =

      
        
        157
        +    { title = "Profile"

      
        
        158
        +    , body =

      
        
        159
        +        [ H.div [ A.class "w-full p-6 max-w-4xl mx-auto" ]

      
        
        160
        +            [ H.div [ A.class "rounded-lg border border-gray-200 shadow-sm" ]

      
        
        161
        +                [ H.div [ A.class "p-6 border-b border-gray-200" ]

      
        
        162
        +                    [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e))

      
        
        163
        +                    , H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text "Account Settings" ]

      
        
        164
        +                    , H.p [ A.class "text-gray-600" ] [ H.text "Manage your account preferences and security settings" ]

      
        
        165
        +                    ]

      
        
        166
        +                , H.div [ A.class "flex" ]

      
        
        167
        +                    [ viewNavigationSidebar model

      
        
        168
        +                    , H.div [ A.class "flex-1 p-6" ]

      
        
        169
        +                        [ case model.me of

      
        
        170
        +                            Api.Success me ->

      
        
        171
        +                                case model.view of

      
        
        172
        +                                    Overview ->

      
        
        173
        +                                        viewOverview shared me

      
        
        174
        +

      
        
        175
        +                                    Password ->

      
        
        176
        +                                        viewPassword model.password (isFormDisabled model) model.isFormSentSuccessfully

      
        
        177
        +

      
        
        178
        +                                    Email ->

      
        
        179
        +                                        viewEmail me model.email (isFormDisabled model) model.isFormSentSuccessfully

      
        
        180
        +

      
        
        181
        +                            Api.Loading ->

      
        
        182
        +                                H.text "Loading..."

      
        
        183
        +

      
        
        184
        +                            Api.Failure err ->

      
        
        185
        +                                H.text ("ERROR: " ++ Api.errorMessage err)

      
        
        186
        +                        ]

      
        
        187
        +                    ]

      
        
        188
        +                ]

      
        
        189
        +            ]

      
        
        190
        +        ]

      
        
        191
        +    }

      
        
        192
        +

      
        
        193
        +

      
        
        194
        +isFormDisabled : Model -> Bool

      
        
        195
        +isFormDisabled model =

      
        
        196
        +    case model.view of

      
        
        197
        +        Overview ->

      
        
        198
        +            True

      
        
        199
        +

      
        
        200
        +        Password ->

      
        
        201
        +            (Validators.password model.password.new /= Nothing)

      
        
        202
        +                || (Validators.passwords model.password.new model.password.confirm /= Nothing)

      
        
        203
        +

      
        
        204
        +        Email ->

      
        
        205
        +            Validators.email model.email /= Nothing

      
        
        206
        +

      
        
        207
        +

      
        
        208
        +viewNavigationSidebar : Model -> Html Msg

      
        
        209
        +viewNavigationSidebar model =

      
        
        210
        +    let

      
        
        211
        +        button variant text =

      
        
        212
        +            Components.Form.button

      
        
        213
        +                { text = text

      
        
        214
        +                , onClick = UserChangedView variant

      
        
        215
        +                , disabled = model.view == variant

      
        
        216
        +                , style = Components.Form.PrimaryReverse (model.view == variant)

      
        
        217
        +                }

      
        
        218
        +    in

      
        
        219
        +    H.div [ A.class "w-64 border-r border-gray-200 p-6" ]

      
        
        220
        +        [ H.nav [ A.class "[&>*]:w-full space-y-2" ]

      
        
        221
        +            [ button Overview "Overview"

      
        
        222
        +            , button Password "Password"

      
        
        223
        +            , button Email "Email"

      
        
        224
        +            ]

      
        
        225
        +        ]

      
        
        226
        +

      
        
        227
        +

      
        
        228
        +viewOverview : Shared.Model -> Me -> Html Msg

      
        
        229
        +viewOverview shared me =

      
        
        230
        +    let

      
        
        231
        +        infoBox title text =

      
        
        232
        +            H.div [ A.class "bg-gray-50 rounded-lg p-4" ]

      
        
        233
        +                [ H.div [ A.class "flex items-center gap-3 mb-2" ]

      
        
        234
        +                    [ H.h3 [ A.class "font-medium text-gray-900" ] [ H.text title ] ]

      
        
        235
        +                , H.p [ A.class "text-gray-700" ] [ H.text text ]

      
        
        236
        +                ]

      
        
        237
        +    in

      
        
        238
        +    viewWrapper

      
        
        239
        +        { title = "Account Overview"

      
        
        240
        +        , body =

      
        
        241
        +            H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ]

      
        
        242
        +                [ infoBox "Email Address" me.email

      
        
        243
        +                , infoBox "Member Since" (Time.Format.toString shared.timeZone me.createdAt)

      
        
        244
        +                , infoBox "Last Login" (Time.Format.toString shared.timeZone me.lastLoginAt)

      
        
        245
        +                , infoBox "Total Notes Created" (String.fromInt me.notesCreated)

      
        
        246
        +                ]

      
        
        247
        +        }

      
        
        248
        +

      
        
        249
        +

      
        
        250
        +viewPassword : { current : String, new : String, confirm : String } -> Bool -> Bool -> Html Msg

      
        
        251
        +viewPassword password isButtonDisabled isFormSentSuccessfully =

      
        
        252
        +    let

      
        
        253
        +        input : { label : String, field : Field, value : String, error : Maybe String } -> Html Msg

      
        
        254
        +        input { label, field, value, error } =

      
        
        255
        +            Components.Form.input

      
        
        256
        +                { label = label

      
        
        257
        +                , id = label

      
        
        258
        +                , field = field

      
        
        259
        +                , onInput = UserChangedField field

      
        
        260
        +                , placeholder = ""

      
        
        261
        +                , value = value

      
        
        262
        +                , required = True

      
        
        263
        +                , type_ = "password"

      
        
        264
        +                , style = Components.Form.Simple

      
        
        265
        +                , error = error

      
        
        266
        +                }

      
        
        267
        +    in

      
        
        268
        +    viewWrapper

      
        
        269
        +        { title = "Change Password"

      
        
        270
        +        , body =

      
        
        271
        +            H.form

      
        
        272
        +                [ A.class "space-y-4 max-w-md"

      
        
        273
        +                , Html.Events.onSubmit UserClickedSubmit

      
        
        274
        +                ]

      
        
        275
        +                [ Components.Utils.viewIf isFormSentSuccessfully (Components.Box.successText "Password updated successfully!")

      
        
        276
        +                , input { label = "Current Password", field = PasswordCurrent, value = password.current, error = Nothing }

      
        
        277
        +                , input { label = "New Password", field = PasswordNew, value = password.new, error = Validators.password password.new }

      
        
        278
        +                , input { label = "Confirm New Password", field = PasswordConfirm, value = password.confirm, error = Validators.passwords password.new password.confirm }

      
        
        279
        +                , Components.Form.submitButton

      
        
        280
        +                    { disabled = isButtonDisabled

      
        
        281
        +                    , text = "Change Password"

      
        
        282
        +                    , style = Components.Form.Primary isButtonDisabled

      
        
        283
        +                    , class = ""

      
        
        284
        +                    }

      
        
        285
        +                ]

      
        
        286
        +        }

      
        
        287
        +

      
        
        288
        +

      
        
        289
        +viewEmail : Me -> String -> Bool -> Bool -> Html Msg

      
        
        290
        +viewEmail me email isButtonDisabled isFormSentSuccessfully =

      
        
        291
        +    viewWrapper

      
        
        292
        +        { title = "Change Email Address"

      
        
        293
        +        , body =

      
        
        294
        +            H.form

      
        
        295
        +                [ A.class "space-y-4 max-w-md"

      
        
        296
        +                , Html.Events.onSubmit UserClickedSubmit

      
        
        297
        +                ]

      
        
        298
        +                [ H.div [ A.class "mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md" ]

      
        
        299
        +                    [ H.h3 [ A.class "font-medium mb-1" ] [ H.text "Note:" ]

      
        
        300
        +                    , 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." ]

      
        
        301
        +                    , H.p [ A.class "mt-2 text-blue-800 text-sm" ]

      
        
        302
        +                        [ H.span [ A.class "font-medium" ] [ H.text ("Current email: " ++ me.email) ]

      
        
        303
        +                        ]

      
        
        304
        +                    ]

      
        
        305
        +                , Components.Utils.viewIf isFormSentSuccessfully (Components.Box.successText "Email updated successfully! Please check your new email for verification.")

      
        
        306
        +                , Components.Form.input

      
        
        307
        +                    { style = Components.Form.Simple

      
        
        308
        +                    , id = "new-email"

      
        
        309
        +                    , type_ = "email"

      
        
        310
        +                    , field = EmailNew

      
        
        311
        +                    , label = "New Email Address"

      
        
        312
        +                    , value = email

      
        
        313
        +                    , placeholder = "Enter your new email address"

      
        
        314
        +                    , onInput = UserChangedField EmailNew

      
        
        315
        +                    , error = Validators.email email

      
        
        316
        +                    , required = True

      
        
        317
        +                    }

      
        
        318
        +                , Components.Form.submitButton

      
        
        319
        +                    { disabled = isButtonDisabled

      
        
        320
        +                    , text = "Update Email"

      
        
        321
        +                    , style = Components.Form.Primary isButtonDisabled

      
        
        322
        +                    , class = ""

      
        
        323
        +                    }

      
        
        324
        +                ]

      
        
        325
        +        }

      
        
        326
        +

      
        
        327
        +

      
        
        328
        +viewWrapper : { title : String, body : Html Msg } -> Html Msg

      
        
        329
        +viewWrapper { title, body } =

      
        
        330
        +    H.div [ A.class "space-y-6" ]

      
        
        331
        +        [ H.div []

      
        
        332
        +            [ H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-4" ] [ H.text title ]

      
        
        333
        +            , body

      
        
        334
        +            ]

      
        
        335
        +        ]

      
D web/src/Pages/Profile/Me.elm
···
        1
        
        -module Pages.Profile.Me exposing (Model, Msg, page)

      
        2
        
        -

      
        3
        
        -import Api

      
        4
        
        -import Api.Me

      
        5
        
        -import Auth

      
        6
        
        -import Data.Me exposing (Me)

      
        7
        
        -import Effect exposing (Effect)

      
        8
        
        -import Html exposing (Html)

      
        9
        
        -import Layouts

      
        10
        
        -import Page exposing (Page)

      
        11
        
        -import Route exposing (Route)

      
        12
        
        -import Shared

      
        13
        
        -import Time.Format as T

      
        14
        
        -import View exposing (View)

      
        15
        
        -

      
        16
        
        -

      
        17
        
        -page : Auth.User -> Shared.Model -> Route () -> Page Model Msg

      
        18
        
        -page _ shared _ =

      
        19
        
        -    Page.new

      
        20
        
        -        { init = init shared

      
        21
        
        -        , update = update

      
        22
        
        -        , subscriptions = subscriptions

      
        23
        
        -        , view = view shared

      
        24
        
        -        }

      
        25
        
        -        |> Page.withLayout (\_ -> Layouts.Header {})

      
        26
        
        -

      
        27
        
        -

      
        28
        
        -

      
        29
        
        --- INIT

      
        30
        
        -

      
        31
        
        -

      
        32
        
        -type alias Model =

      
        33
        
        -    { me : Api.Response Me }

      
        34
        
        -

      
        35
        
        -

      
        36
        
        -init : Shared.Model -> () -> ( Model, Effect Msg )

      
        37
        
        -init _ () =

      
        38
        
        -    ( { me = Api.Loading }

      
        39
        
        -    , Api.Me.get { onResponse = ApiMeResponded }

      
        40
        
        -    )

      
        41
        
        -

      
        42
        
        -

      
        43
        
        -

      
        44
        
        --- UPDATE

      
        45
        
        -

      
        46
        
        -

      
        47
        
        -type Msg

      
        48
        
        -    = ApiMeResponded (Result Api.Error Me)

      
        49
        
        -

      
        50
        
        -

      
        51
        
        -update : Msg -> Model -> ( Model, Effect Msg )

      
        52
        
        -update msg model =

      
        53
        
        -    case msg of

      
        54
        
        -        ApiMeResponded (Ok userData) ->

      
        55
        
        -            ( { model | me = Api.Success userData }, Effect.none )

      
        56
        
        -

      
        57
        
        -        ApiMeResponded (Err error) ->

      
        58
        
        -            ( { model | me = Api.Failure error }, Effect.none )

      
        59
        
        -

      
        60
        
        -

      
        61
        
        -

      
        62
        
        --- SUBSCRIPTIONS

      
        63
        
        -

      
        64
        
        -

      
        65
        
        -subscriptions : Model -> Sub Msg

      
        66
        
        -subscriptions _ =

      
        67
        
        -    Sub.none

      
        68
        
        -

      
        69
        
        -

      
        70
        
        -

      
        71
        
        --- VIEW

      
        72
        
        -

      
        73
        
        -

      
        74
        
        -view : Shared.Model -> Model -> View Msg

      
        75
        
        -view shared model =

      
        76
        
        -    { title = "Profile"

      
        77
        
        -    , body = [ viewProfileContent shared model.me ]

      
        78
        
        -    }

      
        79
        
        -

      
        80
        
        -

      
        81
        
        -viewProfileContent : Shared.Model -> Api.Response Me -> Html Msg

      
        82
        
        -viewProfileContent shared userResponse =

      
        83
        
        -    case userResponse of

      
        84
        
        -        Api.Loading ->

      
        85
        
        -            Html.text "Loading..."

      
        86
        
        -

      
        87
        
        -        Api.Success user ->

      
        88
        
        -            viewUserDetails shared user

      
        89
        
        -

      
        90
        
        -        Api.Failure err ->

      
        91
        
        -            Html.text (Api.errorMessage err)

      
        92
        
        -

      
        93
        
        -

      
        94
        
        -viewUserDetails : Shared.Model -> Me -> Html Msg

      
        95
        
        -viewUserDetails shared me =

      
        96
        
        -    Html.div []

      
        97
        
        -        [ Html.p [] [ Html.text ("Email: " ++ me.email) ]

      
        98
        
        -        , Html.p [] [ Html.text ("Joined: " ++ T.toString shared.timeZone me.createdAt) ]

      
        99
        
        -        ]

      
A web/src/Validators.elm
···
        
        1
        +module Validators exposing (email, password, passwords)

      
        
        2
        +

      
        
        3
        +

      
        
        4
        +email : String -> Maybe String

      
        
        5
        +email inp =

      
        
        6
        +    if

      
        
        7
        +        not (String.isEmpty inp)

      
        
        8
        +            && (not (String.contains "@" inp) && not (String.contains "." inp))

      
        
        9
        +    then

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

      
        
        11
        +

      
        
        12
        +    else

      
        
        13
        +        Nothing

      
        
        14
        +

      
        
        15
        +

      
        
        16
        +password : String -> Maybe String

      
        
        17
        +password passwd =

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

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

      
        
        20
        +

      
        
        21
        +    else

      
        
        22
        +        Nothing

      
        
        23
        +

      
        
        24
        +

      
        
        25
        +passwords : String -> String -> Maybe String

      
        
        26
        +passwords passowrd1 password2 =

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

      
        
        28
        +        Just "Passwords do not match."

      
        
        29
        +

      
        
        30
        +    else

      
        
        31
        +        Nothing

      
M web/tests/UnitTests/Data/Me.elm
···
        14
        14
                         """

      
        15
        15
                         {

      
        16
        16
                           "email": "admin@onasty.local",

      
        17
        
        -                  "created_at": "2025-06-06T19:44:17.370068Z"

      
        
        17
        +                  "created_at": "2025-06-06T19:44:17.370068Z",

      
        
        18
        +                  "last_login_at": "2025-07-06T17:15:23.380068Z",

      
        
        19
        +                  "notes_created": 42

      
        18
        20
                         }

      
        19
        21
                         """

      
        20
        22
                             |> Json.decodeString Data.Me.decode