11 files changed,
452 insertions(+),
167 deletions(-)
Author:
Olexandr Smirnov
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-08-22 19:56:15 +0300
Parent:
234f764
jump to
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/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