@@ -1,4 +1,5 @@
APP_URL=http://localhost:8000 +FRONTEND_URL=http://localhost:1234 NATS_URL="nats:4222" METRICS_ENABLED=true METRICS_PORT=8001
@@ -6,7 +6,9 @@ "strconv"
) type Config struct { - AppURL string + AppURL string + FrontendURL string + NatsURL string MailgunFrom string MailgunDomain string@@ -23,6 +25,7 @@
func NewConfig() *Config { return &Config{ AppURL: getenvOrDefault("APP_URL", ""), + FrontendURL: getenvOrDefault("FRONTEND_URL", ""), NatsURL: getenvOrDefault("NATS_URL", ""), MailgunFrom: getenvOrDefault("MAILGUN_FROM", ""), MailgunDomain: getenvOrDefault("MAILGUN_DOMAIN", ""),
@@ -51,7 +51,7 @@ return err
} mg := NewMailgun(cfg.MailgunFrom, cfg.MailgunDomain, cfg.MailgunAPIKey) - service := NewService(cfg.AppURL, mg) + service := NewService(cfg.AppURL, cfg.FrontendURL, mg) handlers := NewHandlers(service) if err := handlers.RegisterAll(svc); err != nil {
@@ -6,14 +6,17 @@ "log/slog"
) type Service struct { - appURL string - mg *Mailgun + appURL string + frontendURL string + + mg *Mailgun } -func NewService(appURL string, mg *Mailgun) *Service { +func NewService(appURL, frontendURL string, mg *Mailgun) *Service { return &Service{ - appURL: appURL, - mg: mg, + appURL: appURL, + frontendURL: frontendURL, + mg: mg, } }@@ -23,7 +26,7 @@ cancel context.CancelFunc,
receiver, templateName string, templateOpts map[string]string, ) error { - tmpl, err := getTemplate(s.appURL, templateName) + tmpl, err := getTemplate(s.appURL, s.frontendURL, templateName) if err != nil { return err }
@@ -5,6 +5,8 @@ "errors"
"fmt" ) +var ErrInvalidTemplate = errors.New("failed to get template") + type Template struct { Subject string Body string@@ -12,14 +14,14 @@ }
type TemplateFunc func(args map[string]string) Template -func getTemplate(appURL string, templateName string) (TemplateFunc, error) { +func getTemplate(appURL, frontendURL string, templateName string) (TemplateFunc, error) { switch templateName { case "email_verification": return emailVerificationTemplate(appURL), nil case "reset_password": - return passwordResetTemplate(appURL), nil + return passwordResetTemplate(frontendURL), nil default: - return nil, errors.New("failed to get template") //nolint:err113 + return nil, ErrInvalidTemplate } }@@ -36,16 +38,15 @@ }
} } -func passwordResetTemplate(appURL string) TemplateFunc { +func passwordResetTemplate(frontendURL string) TemplateFunc { return func(opts map[string]string) Template { return Template{ Subject: "Onasty: reset your password", - // TODO: when ui is ready, change the link to the ui Body: fmt.Sprintf(`To reset your password, use this api: -<a href="%[1]s/api/v1/auth/reset-password/%[2]s">%[1]s/api/v1/auth/reset-password/%[2]s</a> +<a href="%[1]s/auth?token=%[2]s">%[1]s/auth?token=%[2]s</a> <br /> <br /> -This link will expire after an hour.`, appURL, opts["token"]), +This link will expire after an hour.`, frontendURL, opts["token"]), } } }
@@ -1,4 +1,4 @@
-module Api.Auth exposing (refreshToken, resendVerificationEmail, signin, signup) +module Api.Auth exposing (forgotPassword, refreshToken, resendVerificationEmail, resetPassword, signin, signup) import Api import Data.Credentials as Credentials exposing (Credentials)@@ -72,6 +72,28 @@ , method = "POST"
, body = Http.jsonBody body , onResponse = options.onResponse , decoder = Credentials.decode + } + + +forgotPassword : { onResponse : Result Api.Error () -> msg, email : String } -> Effect msg +forgotPassword options = + Effect.sendApiRequest + { endpoint = "/api/v1/auth/reset-password" + , method = "POST" + , body = Encode.object [ ( "email", Encode.string options.email ) ] |> Http.jsonBody + , onResponse = options.onResponse + , decoder = Decode.succeed () + } + + +resetPassword : { onResponse : Result Api.Error () -> msg, token : String, password : String } -> Effect msg +resetPassword options = + Effect.sendApiRequest + { endpoint = "/api/v1/auth/reset-password/" ++ options.token + , method = "POST" + , body = Encode.object [ ( "password", Encode.string options.password ) ] |> Http.jsonBody + , onResponse = options.onResponse + , decoder = Decode.succeed () }
@@ -0,0 +1,23 @@
+module Components.Box exposing (error, success, successBox) + +import Html as H exposing (Html) +import Html.Attributes as A + + +error : String -> Html msg +error errorMsg = + H.div [ A.class "bg-red-50 border border-red-200 rounded-md p-4" ] + [ H.p [ A.class "text-red-800 text-sm" ] [ H.text errorMsg ] ] + + +success : { header : String, body : String } -> Html msg +success opts = + 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 ] + ] + + +successBox : List (Html msg) -> Html msg +successBox child = + H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-4" ] child
@@ -1,10 +0,0 @@
-module Components.Error exposing (error) - -import Html as H exposing (Html) -import Html.Attributes as A - - -error : String -> Html msg -error errorMsg = - H.div [ A.class "bg-red-50 border border-red-200 rounded-md p-4" ] - [ H.p [ A.class "text-red-800 text-sm" ] [ H.text errorMsg ] ]
@@ -1,12 +1,13 @@
-module Pages.Auth exposing (Model, Msg, Variant, page) +module Pages.Auth exposing (Banner, Model, Msg, Variant, page) import Api import Api.Auth import Auth.User -import Components.Error +import Components.Box import Components.Form import Components.Utils import Data.Credentials exposing (Credentials) +import Dict import Effect exposing (Effect) import Html as H exposing (Html) import Html.Attributes as A@@ -21,9 +22,9 @@ import View exposing (View)
page : Shared.Model -> Route () -> Page Model Msg -page shared _ = +page shared route = Page.new - { init = init shared + { init = init shared route , update = update , subscriptions = subscriptions , view = view@@ -41,23 +42,30 @@ , password : String
, passwordAgain : String , isSubmittingForm : Bool , formVariant : Variant - , showVerifyBanner : Bool + , banner : Banner , lastClicked : Maybe Posix - , apiError : Maybe Api.Error , now : Maybe Posix } -init : Shared.Model -> () -> ( Model, Effect Msg ) -init shared _ = - ( { formVariant = SignIn +init : Shared.Model -> Route () -> () -> ( Model, Effect Msg ) +init shared route () = + let + formVariant = + case Dict.get "token" route.query of + Just token -> + SetNewPassword token + + Nothing -> + SignIn + in + ( { formVariant = formVariant , isSubmittingForm = False , email = "" , password = "" , passwordAgain = "" - , showVerifyBanner = False , lastClicked = Nothing - , apiError = Nothing + , banner = Hidden , now = Nothing } , case shared.user of@@ -81,6 +89,8 @@ | UserClickedSubmit
| UserClickedResendActivationEmail | ApiSignInResponded (Result Api.Error Credentials) | ApiSignUpResponded (Result Api.Error ()) + | ApiForgotPasswordResponded (Result Api.Error ()) + | ApiSetNewPasswordResponded (Result Api.Error ()) | ApiResendVerificationEmail (Result Api.Error ())@@ -90,9 +100,22 @@ | Password
| PasswordAgain +type alias ResetPasswordToken = + String + + type Variant = SignIn | SignUp + | ForgotPassword + | SetNewPassword ResetPasswordToken + + +type Banner + = Hidden + | ResendVerificationEmail + | Error Api.Error + | CheckEmail update : Msg -> Model -> ( Model, Effect Msg )@@ -102,7 +125,7 @@ Tick now ->
( { model | now = Just now }, Effect.none ) UserClickedSubmit -> - ( { model | isSubmittingForm = True, apiError = Nothing } + ( { model | isSubmittingForm = True } , case model.formVariant of SignIn -> Api.Auth.signin@@ -117,6 +140,12 @@ { onResponse = ApiSignUpResponded
, email = model.email , password = model.password } + + ForgotPassword -> + Api.Auth.forgotPassword { onResponse = ApiForgotPasswordResponded, email = model.email } + + SetNewPassword token -> + Api.Auth.resetPassword { onResponse = ApiSetNewPasswordResponded, token = token, password = model.password } ) UserClickedResendActivationEmail ->@@ -142,33 +171,41 @@
ApiSignInResponded (Ok credentials) -> ( { model | isSubmittingForm = False }, Effect.signin credentials ) - ApiSignInResponded (Err error) -> - if Api.isNotVerified error then - ( { model | isSubmittingForm = False, apiError = Nothing, showVerifyBanner = True }, Effect.none ) + ApiSignInResponded (Err err) -> + if Api.isNotVerified err then + ( { model | isSubmittingForm = False, banner = ResendVerificationEmail }, Effect.none ) else - ( { model | isSubmittingForm = False, apiError = Just error }, Effect.none ) + ( { model | isSubmittingForm = False, banner = Error err }, Effect.none ) ApiSignUpResponded (Ok ()) -> - ( { model | isSubmittingForm = False, showVerifyBanner = True }, Effect.none ) + ( { model | isSubmittingForm = False, banner = ResendVerificationEmail }, Effect.none ) - ApiSignUpResponded (Err error) -> - ( { model | isSubmittingForm = False, apiError = Just error }, Effect.none ) + ApiSignUpResponded (Err err) -> + ( { model | isSubmittingForm = False, banner = Error err }, Effect.none ) ApiResendVerificationEmail (Ok ()) -> - ( { model | apiError = Nothing }, Effect.none ) + ( model, Effect.none ) ApiResendVerificationEmail (Err err) -> - ( { model | apiError = Just err }, Effect.none ) + ( { model | banner = Error err }, Effect.none ) + ApiSetNewPasswordResponded (Ok ()) -> + ( { model | isSubmittingForm = False, formVariant = SignIn, password = "", passwordAgain = "" }, Effect.replaceRoutePath Route.Path.Auth ) + ApiSetNewPasswordResponded (Err err) -> + ( { model | isSubmittingForm = False, banner = Error err }, Effect.none ) --- SUBSCRIPTIONS + ApiForgotPasswordResponded (Ok ()) -> + ( { model | isSubmittingForm = False, banner = CheckEmail }, Effect.none ) + + ApiForgotPasswordResponded (Err err) -> + ( { model | isSubmittingForm = False, banner = Error err }, Effect.none ) subscriptions : Model -> Sub Msg subscriptions model = - if model.showVerifyBanner then + if model.banner == ResendVerificationEmail then Time.every 1000 Tick else@@ -201,16 +238,19 @@
viewBanner : Model -> Html Msg viewBanner model = - case ( model.apiError, model.showVerifyBanner ) of - ( Just error, False ) -> - Components.Error.error (Api.errorMessage error) + case model.banner of + Hidden -> + H.text "" + + Error err -> + Components.Box.error (Api.errorMessage err) + + CheckEmail -> + Components.Box.success { header = "Check your email!", body = "To continue with resetting your password please check the email we've sent." } - ( Nothing, True ) -> + ResendVerificationEmail -> viewVerificationBanner model.now model.lastClicked - _ -> - H.text "" - viewVerificationBanner : Maybe Posix -> Maybe Posix -> Html Msg viewVerificationBanner now lastClicked =@@ -241,7 +281,7 @@ canClick : Bool
canClick = timeLeftSeconds == 0 in - H.div [ A.class "bg-green-50 border border-green-200 rounded-md p-4 mb-4" ] + Components.Box.successBox [ H.div [ A.class "font-medium text-green-800 mb-2" ] [ H.text "Check your email!" ] , 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." ] , H.button@@ -268,6 +308,12 @@ ( "Welcome Back", "Enter your credentials to access your account" )
SignUp -> ( "Create Account", "Enter your information to create your account" ) + + ForgotPassword -> + ( "Forgot Password", "Enter your email to reset your password" ) + + SetNewPassword _ -> + ( "Set New Password", "Enter your new password to reset your account" ) in H.div [ A.class "p-6 pb-4" ] [ H.h1 [ A.class "text-2xl font-bold text-center mb-2" ] [ H.text title ]@@ -325,6 +371,18 @@ , viewFormInput { field = Password, value = model.password }
, viewFormInput { field = PasswordAgain, value = model.passwordAgain } , viewSubmitButton model ] + + ForgotPassword -> + [ viewFormInput { field = Email, value = model.email } + , viewSubmitButton model + ] + + SetNewPassword token -> + [ viewFormInput { field = Password, value = model.password } + , viewFormInput { field = PasswordAgain, value = model.passwordAgain } + , H.input [ A.type_ "hidden", A.value token, A.name "token" ] [] + , viewSubmitButton model + ] )@@ -350,9 +408,7 @@ H.div [ A.class "text-right" ]
[ H.button [ A.class "text-sm text-black hover:underline focus:outline-none" , A.type_ "button" - - -- TODO: implement forgot password - -- , E.onClick (UserChangedFormVariant ForgotPassword) + , E.onClick (UserChangedFormVariant ForgotPassword) ] [ H.text "Forgot password?" ] ]@@ -389,6 +445,15 @@ || String.isEmpty model.password
|| String.isEmpty model.passwordAgain || (model.password /= model.passwordAgain) + ForgotPassword -> + model.isSubmittingForm || String.isEmpty model.email + + SetNewPassword _ -> + model.isSubmittingForm + || String.isEmpty model.password + || String.isEmpty model.passwordAgain + || (model.password /= model.passwordAgain) + fromVariantToLabel : Variant -> String fromVariantToLabel variant =@@ -398,6 +463,12 @@ "Sign In"
SignUp -> "Sign Up" + + ForgotPassword -> + "Forgot Password" + + SetNewPassword _ -> + "Set new password" fromFieldToLabel : Field -> String
@@ -2,7 +2,7 @@ module Pages.Home_ exposing (Model, Msg, PageVariant, page)
import Api import Api.Note -import Components.Error +import Components.Box import Components.Form import Components.Utils import Data.Note as Note@@ -211,7 +211,7 @@ [ H.div [ A.class "w-full max-w-4xl mx-auto" ]
[ H.div [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ] [ viewHeader model.pageVariant , H.div [ A.class "p-6 space-y-6" ] - [ Components.Utils.viewMaybe model.apiError (\e -> Components.Error.error (Api.errorMessage e)) + [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e)) , case model.pageVariant of CreateNote -> viewCreateNoteForm model shared.appURL
@@ -2,7 +2,7 @@ module Pages.Secret.Slug_ exposing (Model, Msg, PageVariant, page)
import Api import Api.Note -import Components.Error +import Components.Box import Components.Utils import Data.Note exposing (Metadata, Note) import Effect exposing (Effect)@@ -145,7 +145,7 @@ , if Api.is404 error then
viewNoteNotFound model.slug else - Components.Error.error (Api.errorMessage error) + Components.Box.error (Api.errorMessage error) ] ) ]