11 files changed,
178 insertions(+),
64 deletions(-)
Author:
Olexandr Smirnov
ss2316544@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-07-22 14:16:33 +0300
Parent:
b650519
M
mailer/config.go
··· 6 6 ) 7 7 8 8 type Config struct { 9 - AppURL string 9 + AppURL string 10 + FrontendURL string 11 + 10 12 NatsURL string 11 13 MailgunFrom string 12 14 MailgunDomain string ··· 23 25 func NewConfig() *Config { 24 26 return &Config{ 25 27 AppURL: getenvOrDefault("APP_URL", ""), 28 + FrontendURL: getenvOrDefault("FRONTEND_URL", ""), 26 29 NatsURL: getenvOrDefault("NATS_URL", ""), 27 30 MailgunFrom: getenvOrDefault("MAILGUN_FROM", ""), 28 31 MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""),
M
mailer/main.go
··· 51 51 } 52 52 53 53 mg := NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey) 54 - service := NewService(cfg.AppURL, mg) 54 + service := NewService(cfg.AppURL, cfg.FrontendURL, mg) 55 55 handlers := NewHandlers(service) 56 56 57 57 if err := handlers.RegisterAll(svc); err != nil {
M
mailer/service.go
··· 6 6 ) 7 7 8 8 type Service struct { 9 - appURL string 10 - mg *Mailgun 9 + appURL string 10 + frontendURL string 11 + 12 + mg *Mailgun 11 13 } 12 14 13 -func NewService(appURL string, mg *Mailgun) *Service { 15 +func NewService(appURL, frontendURL string, mg *Mailgun) *Service { 14 16 return &Service{ 15 - appURL: appURL, 16 - mg: mg, 17 + appURL: appURL, 18 + frontendURL: frontendURL, 19 + mg: mg, 17 20 } 18 21 } 19 22 ··· 23 26 receiver, templateName string, 24 27 templateOpts map[string]string, 25 28 ) error { 26 - tmpl, err := getTemplate(s.appURL, templateName) 29 + tmpl, err := getTemplate(s.appURL, s.frontendURL, templateName) 27 30 if err != nil { 28 31 return err 29 32 }
M
mailer/template.go
··· 5 5 "fmt" 6 6 ) 7 7 8 +var ErrInvalidTemplate = errors.New("failed to get template") 9 + 8 10 type Template struct { 9 11 Subject string 10 12 Body string ··· 12 14 13 15 type TemplateFunc func(args map[string]string) Template 14 16 15 -func getTemplate(appURL string, templateName string) (TemplateFunc, error) { 17 +func getTemplate(appURL, frontendURL string, templateName string) (TemplateFunc, error) { 16 18 switch templateName { 17 19 case "email_verification": 18 20 return emailVerificationTemplate(appURL), nil 19 21 case "reset_password": 20 - return passwordResetTemplate(appURL), nil 22 + return passwordResetTemplate(frontendURL), nil 21 23 default: 22 - return nil, errors.New("failed to get template") //nolint:err113 24 + return nil, ErrInvalidTemplate 23 25 } 24 26 } 25 27 ··· 36 38 } 37 39 } 38 40 39 -func passwordResetTemplate(appURL string) TemplateFunc { 41 +func passwordResetTemplate(frontendURL string) TemplateFunc { 40 42 return func(opts map[string]string) Template { 41 43 return Template{ 42 44 Subject: "Onasty: reset your password", 43 - // TODO: when ui is ready, change the link to the ui 44 45 Body: fmt.Sprintf(`To reset your password, use this api: 45 -<a href="%[1]s/api/v1/auth/reset-password/%[2]s">%[1]s/api/v1/auth/reset-password/%[2]s</a> 46 +<a href="%[1]s/auth?token=%[2]s">%[1]s/auth?token=%[2]s</a> 46 47 <br /> 47 48 <br /> 48 -This link will expire after an hour.`, appURL, opts["token"]), 49 +This link will expire after an hour.`, frontendURL, opts["token"]), 49 50 } 50 51 } 51 52 }
M
web/src/Api/Auth.elm
··· 1 -module Api.Auth exposing (refreshToken, resendVerificationEmail, signin, signup) 1 +module Api.Auth exposing (forgotPassword, refreshToken, resendVerificationEmail, resetPassword, signin, signup) 2 2 3 3 import Api 4 4 import Data.Credentials as Credentials exposing (Credentials) ··· 72 72 , body = Http.jsonBody body 73 73 , onResponse = options.onResponse 74 74 , decoder = Credentials.decode 75 + } 76 + 77 + 78 +forgotPassword : { onResponse : Result Api.Error () -> msg, email : String } -> Effect msg 79 +forgotPassword options = 80 + Effect.sendApiRequest 81 + { endpoint = "/api/v1/auth/reset-password" 82 + , method = "POST" 83 + , body = Encode.object [ ( "email", Encode.string options.email ) ] |> Http.jsonBody 84 + , onResponse = options.onResponse 85 + , decoder = Decode.succeed () 86 + } 87 + 88 + 89 +resetPassword : { onResponse : Result Api.Error () -> msg, token : String, password : String } -> Effect msg 90 +resetPassword options = 91 + Effect.sendApiRequest 92 + { endpoint = "/api/v1/auth/reset-password/" ++ options.token 93 + , method = "POST" 94 + , body = Encode.object [ ( "password", Encode.string options.password ) ] |> Http.jsonBody 95 + , onResponse = options.onResponse 96 + , decoder = Decode.succeed () 75 97 } 76 98 77 99
A
web/src/Components/Box.elm
··· 1 +module Components.Box exposing (error, success, successBox) 2 + 3 +import Html as H exposing (Html) 4 +import Html.Attributes as A 5 + 6 + 7 +error : String -> Html msg 8 +error errorMsg = 9 + H.div [ A.class "bg-red-50 border border-red-200 rounded-md p-4" ] 10 + [ H.p [ A.class "text-red-800 text-sm" ] [ H.text errorMsg ] ] 11 + 12 + 13 +success : { header : String, body : String } -> Html msg 14 +success opts = 15 + successBox 16 + [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text opts.header ] 17 + , H.p [ A.class "text-green-800 text-sm" ] [ H.text opts.body ] 18 + ] 19 + 20 + 21 +successBox : List (Html msg) -> Html msg 22 +successBox child = 23 + H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-4" ] child
D
web/src/Components/Error.elm
··· 1 -module Components.Error exposing (error) 2 - 3 -import Html as H exposing (Html) 4 -import Html.Attributes as A 5 - 6 - 7 -error : String -> Html msg 8 -error errorMsg = 9 - H.div [ A.class "bg-red-50 border border-red-200 rounded-md p-4" ] 10 - [ H.p [ A.class "text-red-800 text-sm" ] [ H.text errorMsg ] ]
M
web/src/Pages/Auth.elm
··· 1 -module Pages.Auth exposing (Model, Msg, Variant, page) 1 +module Pages.Auth exposing (Banner, Model, Msg, Variant, page) 2 2 3 3 import Api 4 4 import Api.Auth 5 5 import Auth.User 6 -import Components.Error 6 +import Components.Box 7 7 import Components.Form 8 8 import Components.Utils 9 9 import Data.Credentials exposing (Credentials) 10 +import Dict 10 11 import Effect exposing (Effect) 11 12 import Html as H exposing (Html) 12 13 import Html.Attributes as A ··· 21 22 22 23 23 24 page : Shared.Model -> Route () -> Page Model Msg 24 -page shared _ = 25 +page shared route = 25 26 Page.new 26 - { init = init shared 27 + { init = init shared route 27 28 , update = update 28 29 , subscriptions = subscriptions 29 30 , view = view ··· 41 42 , passwordAgain : String 42 43 , isSubmittingForm : Bool 43 44 , formVariant : Variant 44 - , showVerifyBanner : Bool 45 + , banner : Banner 45 46 , lastClicked : Maybe Posix 46 - , apiError : Maybe Api.Error 47 47 , now : Maybe Posix 48 48 } 49 49 50 50 51 -init : Shared.Model -> () -> ( Model, Effect Msg ) 52 -init shared _ = 53 - ( { formVariant = SignIn 51 +init : Shared.Model -> Route () -> () -> ( Model, Effect Msg ) 52 +init shared route () = 53 + let 54 + formVariant = 55 + case Dict.get "token" route.query of 56 + Just token -> 57 + SetNewPassword token 58 + 59 + Nothing -> 60 + SignIn 61 + in 62 + ( { formVariant = formVariant 54 63 , isSubmittingForm = False 55 64 , email = "" 56 65 , password = "" 57 66 , passwordAgain = "" 58 - , showVerifyBanner = False 59 67 , lastClicked = Nothing 60 - , apiError = Nothing 68 + , banner = Hidden 61 69 , now = Nothing 62 70 } 63 71 , case shared.user of ··· 81 89 | UserClickedResendActivationEmail 82 90 | ApiSignInResponded (Result Api.Error Credentials) 83 91 | ApiSignUpResponded (Result Api.Error ()) 92 + | ApiForgotPasswordResponded (Result Api.Error ()) 93 + | ApiSetNewPasswordResponded (Result Api.Error ()) 84 94 | ApiResendVerificationEmail (Result Api.Error ()) 85 95 86 96 ··· 90 100 | PasswordAgain 91 101 92 102 103 +type alias ResetPasswordToken = 104 + String 105 + 106 + 93 107 type Variant 94 108 = SignIn 95 109 | SignUp 110 + | ForgotPassword 111 + | SetNewPassword ResetPasswordToken 112 + 113 + 114 +type Banner 115 + = Hidden 116 + | ResendVerificationEmail 117 + | Error Api.Error 118 + | CheckEmail 96 119 97 120 98 121 update : Msg -> Model -> ( Model, Effect Msg ) ··· 102 125 ( { model | now = Just now }, Effect.none ) 103 126 104 127 UserClickedSubmit -> 105 - ( { model | isSubmittingForm = True, apiError = Nothing } 128 + ( { model | isSubmittingForm = True } 106 129 , case model.formVariant of 107 130 SignIn -> 108 131 Api.Auth.signin ··· 117 140 , email = model.email 118 141 , password = model.password 119 142 } 143 + 144 + ForgotPassword -> 145 + Api.Auth.forgotPassword { onResponse = ApiForgotPasswordResponded, email = model.email } 146 + 147 + SetNewPassword token -> 148 + Api.Auth.resetPassword { onResponse = ApiSetNewPasswordResponded, token = token, password = model.password } 120 149 ) 121 150 122 151 UserClickedResendActivationEmail -> ··· 142 171 ApiSignInResponded (Ok credentials) -> 143 172 ( { model | isSubmittingForm = False }, Effect.signin credentials ) 144 173 145 - ApiSignInResponded (Err error) -> 146 - if Api.isNotVerified error then 147 - ( { model | isSubmittingForm = False, apiError = Nothing, showVerifyBanner = True }, Effect.none ) 174 + ApiSignInResponded (Err err) -> 175 + if Api.isNotVerified err then 176 + ( { model | isSubmittingForm = False, banner = ResendVerificationEmail }, Effect.none ) 148 177 149 178 else 150 - ( { model | isSubmittingForm = False, apiError = Just error }, Effect.none ) 179 + ( { model | isSubmittingForm = False, banner = Error err }, Effect.none ) 151 180 152 181 ApiSignUpResponded (Ok ()) -> 153 - ( { model | isSubmittingForm = False, showVerifyBanner = True }, Effect.none ) 182 + ( { model | isSubmittingForm = False, banner = ResendVerificationEmail }, Effect.none ) 154 183 155 - ApiSignUpResponded (Err error) -> 156 - ( { model | isSubmittingForm = False, apiError = Just error }, Effect.none ) 184 + ApiSignUpResponded (Err err) -> 185 + ( { model | isSubmittingForm = False, banner = Error err }, Effect.none ) 157 186 158 187 ApiResendVerificationEmail (Ok ()) -> 159 - ( { model | apiError = Nothing }, Effect.none ) 188 + ( model, Effect.none ) 160 189 161 190 ApiResendVerificationEmail (Err err) -> 162 - ( { model | apiError = Just err }, Effect.none ) 191 + ( { model | banner = Error err }, Effect.none ) 163 192 193 + ApiSetNewPasswordResponded (Ok ()) -> 194 + ( { model | isSubmittingForm = False, formVariant = SignIn, password = "", passwordAgain = "" }, Effect.replaceRoutePath Route.Path.Auth ) 164 195 196 + ApiSetNewPasswordResponded (Err err) -> 197 + ( { model | isSubmittingForm = False, banner = Error err }, Effect.none ) 165 198 166 --- SUBSCRIPTIONS 199 + ApiForgotPasswordResponded (Ok ()) -> 200 + ( { model | isSubmittingForm = False, banner = CheckEmail }, Effect.none ) 201 + 202 + ApiForgotPasswordResponded (Err err) -> 203 + ( { model | isSubmittingForm = False, banner = Error err }, Effect.none ) 167 204 168 205 169 206 subscriptions : Model -> Sub Msg 170 207 subscriptions model = 171 - if model.showVerifyBanner then 208 + if model.banner == ResendVerificationEmail then 172 209 Time.every 1000 Tick 173 210 174 211 else ··· 201 238 202 239 viewBanner : Model -> Html Msg 203 240 viewBanner model = 204 - case ( model.apiError, model.showVerifyBanner ) of 205 - ( Just error, False ) -> 206 - Components.Error.error (Api.errorMessage error) 241 + case model.banner of 242 + Hidden -> 243 + H.text "" 244 + 245 + Error err -> 246 + Components.Box.error (Api.errorMessage err) 247 + 248 + CheckEmail -> 249 + Components.Box.success { header = "Check your email!", body = "To continue with resetting your password please check the email we've sent." } 207 250 208 - ( Nothing, True ) -> 251 + ResendVerificationEmail -> 209 252 viewVerificationBanner model.now model.lastClicked 210 253 211 - _ -> 212 - H.text "" 213 - 214 254 215 255 viewVerificationBanner : Maybe Posix -> Maybe Posix -> Html Msg 216 256 viewVerificationBanner now lastClicked = ··· 241 281 canClick = 242 282 timeLeftSeconds == 0 243 283 in 244 - H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-4 mb-4" ] 284 + Components.Box.successBox 245 285 [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text "Check your email!" ] 246 286 , H.p [ A.class "text-green-800 text-sm" ] [ H.text "Please verify your account to continue. We've sent a verification link to your email — click it to activate your account." ] 247 287 , H.button ··· 268 308 269 309 SignUp -> 270 310 ( "Create Account", "Enter your information to create your account" ) 311 + 312 + ForgotPassword -> 313 + ( "Forgot Password", "Enter your email to reset your password" ) 314 + 315 + SetNewPassword _ -> 316 + ( "Set New Password", "Enter your new password to reset your account" ) 271 317 in 272 318 H.div [ A.class "p-6 pb-4" ] 273 319 [ H.h1 [ A.class "text-2xl font-bold text-center mb-2" ] [ H.text title ] ··· 325 371 , viewFormInput { field = PasswordAgain, value = model.passwordAgain } 326 372 , viewSubmitButton model 327 373 ] 374 + 375 + ForgotPassword -> 376 + [ viewFormInput { field = Email, value = model.email } 377 + , viewSubmitButton model 378 + ] 379 + 380 + SetNewPassword token -> 381 + [ viewFormInput { field = Password, value = model.password } 382 + , viewFormInput { field = PasswordAgain, value = model.passwordAgain } 383 + , H.input [ A.type_ "hidden", A.value token, A.name "token" ] [] 384 + , viewSubmitButton model 385 + ] 328 386 ) 329 387 330 388 ··· 350 408 [ H.button 351 409 [ A.class "text-sm text-black hover:underline focus:outline-none" 352 410 , A.type_ "button" 353 - 354 - -- TODO: implement forgot password 355 - -- , E.onClick (UserChangedFormVariant ForgotPassword) 411 + , E.onClick (UserChangedFormVariant ForgotPassword) 356 412 ] 357 413 [ H.text "Forgot password?" ] 358 414 ] ··· 389 445 || String.isEmpty model.passwordAgain 390 446 || (model.password /= model.passwordAgain) 391 447 448 + ForgotPassword -> 449 + model.isSubmittingForm || String.isEmpty model.email 450 + 451 + SetNewPassword _ -> 452 + model.isSubmittingForm 453 + || String.isEmpty model.password 454 + || String.isEmpty model.passwordAgain 455 + || (model.password /= model.passwordAgain) 456 + 392 457 393 458 fromVariantToLabel : Variant -> String 394 459 fromVariantToLabel variant = ··· 398 463 399 464 SignUp -> 400 465 "Sign Up" 466 + 467 + ForgotPassword -> 468 + "Forgot Password" 469 + 470 + SetNewPassword _ -> 471 + "Set new password" 401 472 402 473 403 474 fromFieldToLabel : Field -> String
M
web/src/Pages/Home_.elm
··· 2 2 3 3 import Api 4 4 import Api.Note 5 -import Components.Error 5 +import Components.Box 6 6 import Components.Form 7 7 import Components.Utils 8 8 import Data.Note as Note ··· 211 211 [ H.div [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ] 212 212 [ viewHeader model.pageVariant 213 213 , H.div [ A.class "p-6 space-y-6" ] 214 - [ Components.Utils.viewMaybe model.apiError (\e -> Components.Error.error (Api.errorMessage e)) 214 + [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e)) 215 215 , case model.pageVariant of 216 216 CreateNote -> 217 217 viewCreateNoteForm model shared.appURL
M
web/src/Pages/Secret/Slug_.elm
··· 2 2 3 3 import Api 4 4 import Api.Note 5 -import Components.Error 5 +import Components.Box 6 6 import Components.Utils 7 7 import Data.Note exposing (Metadata, Note) 8 8 import Effect exposing (Effect) ··· 145 145 viewNoteNotFound model.slug 146 146 147 147 else 148 - Components.Error.error (Api.errorMessage error) 148 + Components.Box.error (Api.errorMessage error) 149 149 ] 150 150 ) 151 151 ]