feat(user): user registration form
nclazz/soundwerft/pipeline/head This commit looks good Details

closes: #55
master
Niclas Thobaben 2024-03-02 11:46:04 +01:00
parent fa4a9c2861
commit a00d768d9c
12 changed files with 303 additions and 55 deletions

View File

@ -62,7 +62,7 @@ func postLogin(h *handler.Handler) echo.HandlerFunc {
sess.Values[handler.SessionKeyUser] = &handler.SessionUser{
ID: usr.ID,
Username: usr.Name.String(),
IsAdmin: true,
IsAdmin: usr.IsAdmin,
Email: usr.Email.Address,
}
if err := h.SaveSession(c); err != nil {

View File

@ -1,6 +1,11 @@
package user
import "github.com/nicksnyder/go-i18n/v2/i18n"
import (
"errors"
"git.l--n.de/nclazz/soundwerft/api/handler"
"git.l--n.de/nclazz/soundwerft/internal/app/user"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
var (
MsgIncorrectUsernameOrPassword = &i18n.Message{
@ -57,7 +62,7 @@ var (
}
MsgConfirmPasswordLabel = &i18n.Message{
ID: "User.ConfirmPasswordLabel",
Other: "Confirm new password",
Other: "Confirm password",
}
MsgPasswordSaved = &i18n.Message{
ID: "User.PasswordSaved",
@ -76,4 +81,74 @@ var (
ID: "User.ErrWrongPasswordConfirmation",
Other: "The provided password did not match the new password",
}
MsgCreateAccount = &i18n.Message{
ID: "User.CreateAccount",
Other: "Create Account",
}
MsgPasswordLabel = &i18n.Message{
ID: "User.PasswordLabel",
Other: "Password",
}
MsgAlreadyHaveAccount = &i18n.Message{
ID: "User.AlreadyHaveAccount",
Other: "Already have an account?",
}
MsgLoginNow = &i18n.Message{
ID: "User.LoginNow",
Other: "Login now",
}
MsgErrCreateAccountFailed = &i18n.Message{
ID: "User.ErrRegistration",
Other: "Failed to create account. Please correct your input and try again",
}
MsgErrUsernameAlreadyExists = &i18n.Message{
ID: "User.ErrUsernameAlreadyExists",
Other: "Another user with this username already exists",
}
MsgErrEmailAlreadyExists = &i18n.Message{
ID: "User.ErrEmailAlreadyExists",
Other: "Another user with this email address already exists",
}
MsgErrInvalidName = &i18n.Message{
ID: "User.ErrInvalidName",
Other: "Not a valid name. Must only include alphanumeric characters or _",
}
// TODO needs a different approach when implementing policies in #64
MsgErrInvalidPassword = &i18n.Message{
ID: "User.ErrInvalidPassword",
Other: "Not a valid password",
}
MsgErrInvalidEmail = &i18n.Message{
ID: "User.ErrInvalidEmail",
Other: "Not a valid email address",
}
MsgErrUserNotFound = &i18n.Message{
ID: "User.ErrUserNotFound",
Other: "User does not exist",
}
)
func TranslateError(h *handler.Handler, err error) string {
if errors.Is(err, user.ErrInvalidName) {
return h.TranslateMessage(MsgErrInvalidName)
}
if errors.Is(err, user.ErrInvalidPassword) {
return h.TranslateMessage(MsgErrInvalidPassword)
}
if errors.Is(err, user.ErrInvalidEmail) {
return h.TranslateMessage(MsgErrInvalidEmail)
}
if errors.Is(err, user.ErrUsernameAlreadyExists) {
return h.TranslateMessage(MsgErrUsernameAlreadyExists)
}
if errors.Is(err, user.ErrEmailAlreadyExists) {
return h.TranslateMessage(MsgErrEmailAlreadyExists)
}
if errors.Is(err, user.ErrRegistrationFailed) {
return h.TranslateMessage(MsgErrCreateAccountFailed)
}
if errors.Is(err, user.ErrUserNotFound) {
return h.TranslateMessage(MsgErrUserNotFound)
}
return ""
}

View File

@ -0,0 +1,84 @@
package user
import (
"errors"
"git.l--n.de/nclazz/soundwerft/api/handler"
"git.l--n.de/nclazz/soundwerft/api/web/templates"
userApi "git.l--n.de/nclazz/soundwerft/internal/app/user"
"git.l--n.de/nclazz/soundwerft/pkg/errx"
"github.com/labstack/echo/v4"
"log/slog"
)
func registerFormView(c echo.Context, h *handler.Handler) templates.RegisterFormView {
return templates.RegisterFormView{
Title: h.TranslateMessage(MsgCreateAccount),
MsgUsernameLabel: h.TranslateMessage(MsgUsernameLabel),
MsgEmailLabel: h.TranslateMessage(MsgEmailLabel),
MsgPasswordLabel: h.TranslateMessage(MsgPasswordLabel),
MsgPasswordConfirmLabel: h.TranslateMessage(MsgConfirmPasswordLabel),
MsgAlreadyHaveAccount: h.TranslateMessage(MsgAlreadyHaveAccount),
MsgLoginNow: h.TranslateMessage(MsgLoginNow),
MsgRegister: h.TranslateMessage(MsgCreateAccount),
}
}
func getRegisterPage(h *handler.Handler) echo.HandlerFunc {
return func(c echo.Context) error {
return h.Render(c, templates.RegisterPage(h.View(c), registerFormView(c, h)))
}
}
func postRegister(h *handler.Handler) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
name := c.FormValue("name")
email := c.FormValue("email")
password := c.FormValue("password")
passwordConfirm := c.FormValue("password_confirm")
log := h.Logger(c).With(slog.Group("form", "name", name, "email", email))
view := registerFormView(c, h)
view.Name = name
view.Email = email
if password != passwordConfirm {
view.Err = h.TranslateMessage(MsgErrCreateAccountFailed)
view.PasswordConfirmErr = h.TranslateMessage(MsgErrWrongPasswordConfirmation)
return h.Render(c, templates.RegisterForm(h.View(c), view))
}
log.Info("register user")
user, err := h.Services.Users.RegisterUser(ctx, userApi.RegisterUserCommand{
Name: name,
Email: email,
Password: password,
IsAdmin: false,
})
if err != nil {
log.Warn("register user failed", "error", err)
view.Err = h.TranslateMessage(MsgErrCreateAccountFailed)
mapRegistrationErrs(h, &view, err)
return h.Render(c, templates.RegisterForm(h.View(c), view))
}
log.Info("registered user", slog.Group("user", "id", user.ID, "name", user.Name, "email", user.Email))
// TODO add flash message for confirmation
return h.HardRedirect(c, h.View(c).URLString("/login"))
}
}
func mapRegistrationErrs(h *handler.Handler, view *templates.RegisterFormView, err error) {
var errs errx.ErrMap
if errors.As(err, &errs) {
view.NameErr = TranslateError(h, errs.Get("name"))
view.EmailErr = TranslateError(h, errs.Get("email"))
view.PasswordErr = TranslateError(h, errs.Get("password"))
return
}
if errors.Is(err, userApi.ErrUsernameAlreadyExists) {
view.NameErr = TranslateError(h, err)
return
}
if errors.Is(err, userApi.ErrEmailAlreadyExists) {
view.EmailErr = TranslateError(h, err)
return
}
}

View File

@ -16,4 +16,9 @@ func Routes(h *handler.Handler) {
g.PUT("/name", putName(h))
g.PUT("/email", putEmail(h))
g.PUT("/password", putPassword(h))
g = h.Echo.Group("/register")
g.GET("", getRegisterPage(h))
g.GET("/", getRegisterPage(h))
g.POST("", postRegister(h))
}

View File

@ -70,7 +70,7 @@ templ LoginForm(view web.View, form LoginFormView) {
})
<div class="d-grid gap-2">
<input type="submit" class="btn btn-primary btn-lg" value="Login"/>
<p>{ form.MsgNoAccountYet } <a href={ view.URL("/signup") } hx-boost="true"
<p>{ form.MsgNoAccountYet } <a href={ view.URL("/register") } hx-boost="true"
hx-select="#main"
hx-target="#main">{ form.MsgSignUpNow }</a>
</p>

View File

@ -64,12 +64,12 @@ templ DocumentHeader(view web.View) {
hx-select="#main">
Login
</a>
<a href={ view.URL("/signup") }
<a href={ view.URL("/register") }
class="nav-item btn btn-primary"
hx-boost="true"
hx-target="#main"
hx-select="#main">
Signup
Register
</a>
}else {
@UserMenu(view)

View File

@ -0,0 +1,101 @@
package templates
import (
"git.l--n.de/nclazz/soundwerft/api/web"
"git.l--n.de/nclazz/soundwerft/api/web/templates/partials"
)
type RegisterFormView struct {
Err string
Title string
Name string
Email string
NameErr string
EmailErr string
PasswordErr string
PasswordConfirmErr string
MsgUsernameLabel string
MsgEmailLabel string
MsgPasswordLabel string
MsgPasswordConfirmLabel string
MsgAlreadyHaveAccount string
MsgLoginNow string
MsgRegister string
}
templ RegisterPage(view web.View, form RegisterFormView) {
@partials.Document(view) {
@RegisterForm(view, form)
}
}
templ RegisterForm(view web.View, form RegisterFormView) {
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-3">
<h4>{ form.Title }</h4>
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-3">
<form class="form"
method="post"
action={view.URL("/register")}
hx-boost="true"
hx-target="#main"
hx-replace-url="true">
if form.Err != "" {
@partials.Alert("danger") {
{ form.Err }
}
}
@partials.FormInput(partials.InputAttrs{
Name: "name",
Label: form.MsgUsernameLabel,
Value: form.Name,
Placeholder: form.MsgUsernameLabel,
Required: true,
HasErr: form.NameErr != "",
Err: form.NameErr,
})
@partials.FormInput(partials.InputAttrs{
Name: "email",
Label: form.MsgEmailLabel,
Value: form.Email,
Placeholder: form.MsgEmailLabel,
Required: true,
HasErr: form.EmailErr != "",
Err: form.EmailErr,
})
@partials.FormInput(partials.InputAttrs{
Name: "password",
Label: form.MsgPasswordLabel,
Type: "password",
Placeholder: form.MsgPasswordLabel,
Required: true,
HasErr: form.PasswordErr != "",
Err: form.PasswordErr,
})
@partials.FormInput(partials.InputAttrs{
Name: "password_confirm",
Label: form.MsgPasswordConfirmLabel,
Type: "password",
Placeholder: form.MsgPasswordConfirmLabel,
Required: true,
HasErr: form.PasswordConfirmErr != "",
Err: form.PasswordConfirmErr,
})
<div class="d-grid gap-2">
<input type="submit" class="btn btn-primary btn-lg" value={ form.MsgRegister }/>
<p>{ form.MsgAlreadyHaveAccount } <a href={ view.URL("/login") } hx-boost="true"
hx-select="#main"
hx-target="#main">{ form.MsgLoginNow }</a>
</p>
</div>
</form>
</div>
</div>
</div>
}

View File

@ -52,6 +52,6 @@ func main() {
if adminExists {
slog.Info("admin user already exists")
}
ps.Start(ctx)
go ps.Start(ctx)
errx.MustExec(server.Start())
}

View File

@ -2,48 +2,17 @@ package user
import (
"errors"
"github.com/nicksnyder/go-i18n/v2/i18n"
"fmt"
)
var (
ErrInvalidName = errors.New("invalid name")
ErrInvalidPassword = errors.New("invalid password")
ErrInvalidEmail = errors.New("invalid email")
ErrUsernameAlreadyExists = errors.New("username already exists")
ErrEmailAlreadyExists = errors.New("email already exists")
ErrRegistrationFailed = errors.New("user registration failed")
ErrUserNotFound = errors.New("user not found")
ErrInvalidCredentials = errors.New("invalid credentials")
)
var (
TranslationErrInvalidName = &i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "User.ErrInvalidName",
Other: "Username is invalid",
},
}
TranslationErrInvalidPassword = &i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "User.ErrInvalidPassword",
Other: "Password is invalid",
},
}
TranslationErrUsernameAlreadyExists = &i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "User.ErrUsernameAlreadyExists",
Other: "Username already exists",
},
}
TranslationErrEmailAlreadyExists = &i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "User.ErrEmailAlreadyExists",
Other: "Email already exists",
},
}
TranslationErrRegistrationFailed = &i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "User.RegistrationFailed",
Other: "Registration has failed",
},
}
ErrUserError = errors.New("user")
ErrInvalidName = fmt.Errorf("%w: invalid name", ErrUserError)
ErrInvalidPassword = fmt.Errorf("%w: invalid password", ErrUserError)
ErrInvalidEmail = fmt.Errorf("%w: invalid email", ErrUserError)
ErrUsernameAlreadyExists = fmt.Errorf("%w: username already exists", ErrUserError)
ErrEmailAlreadyExists = fmt.Errorf("%w: email already exists", ErrUserError)
ErrRegistrationFailed = fmt.Errorf("%w: user registration failed", ErrUserError)
ErrUserNotFound = fmt.Errorf("%w: user not found", ErrUserError)
ErrInvalidCredentials = fmt.Errorf("%w: invalid credentials", ErrUserError)
)

View File

@ -1,2 +1,2 @@
SW_LOG_LEVEL=debug
SW_LOG_LEVEL=trace
SW_LOG_FORMAT=text

View File

@ -22,6 +22,14 @@ func (m ErrMap) Get(key string) error {
return nil
}
func (m ErrMap) GetString(key string) string {
err := m.Get(key)
if err != nil {
return err.Error()
}
return ""
}
func (m *ErrMap) Has(key string) bool {
_, ok := (*m)[key]

View File

@ -1,25 +1,31 @@
Save = "Save"
"User.ConfirmPasswordLabel" = "Confirm new password"
"User.AlreadyHaveAccount" = "Already have an account?"
"User.ConfirmPasswordLabel" = "Confirm password"
"User.CreateAccount" = "Create Account"
"User.CurrentPasswordLabel" = "Current password"
"User.EmailLabel" = "Email address"
"User.EmailSaved" = "Successfully saved email!"
"User.ErrEmailAlreadyExists" = "Email already exists"
"User.ErrEmailAlreadyExists" = "Another user with this email address already exists"
"User.ErrIncorrectUsernameOrPassword" = "Incorrect username or password."
"User.ErrInvalidName" = "Username is invalid"
"User.ErrInvalidPassword" = "Password is invalid"
"User.ErrInvalidEmail" = "Not a valid email address"
"User.ErrInvalidName" = "Not a valid name. Must only include alphanumeric characters or _"
"User.ErrInvalidPassword" = "Not a valid password"
"User.ErrPasswordNotVerified" = "The provided password was not correct"
"User.ErrPasswordSave" = "Failed to update your password. Please correct your input and try again"
"User.ErrUsernameAlreadyExists" = "Username already exists"
"User.ErrRegistration" = "Failed to create account. Please correct your input and try again"
"User.ErrUserNotFound" = "User does not exist"
"User.ErrUsernameAlreadyExists" = "Another user with this username already exists"
"User.ErrWrongPasswordConfirmation" = "The provided password did not match the new password"
"User.ForgotPassword" = "Forgot Password"
"User.LoginKeepLoggedIn" = "Keep logged in"
"User.LoginNow" = "Login now"
"User.LoginPasswordLabel" = "Password"
"User.LoginUsernameLabel" = "Username or email address"
"User.NewPasswordLabel" = "New password"
"User.NoAccountYet" = "No account yet?"
"User.PasswordLabel" = "Password"
"User.PasswordSaved" = "Successfully updated password!"
"User.RegisterNow" = "Register now"
"User.RegistrationFailed" = "Registration has failed"
"User.UsernameLabel" = "Username"
"User.UsernameSaved" = "Successfully saved username!"