add forget password
This commit is contained in:
parent
7ea809cc09
commit
5a0dec6128
9
config/brevo.go
Normal file
9
config/brevo.go
Normal file
@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
type Brevo struct {
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
}
|
||||
|
||||
func (b *Brevo) GetApiKey() string {
|
||||
return b.APIKey
|
||||
}
|
||||
@ -29,6 +29,8 @@ type Config struct {
|
||||
Jwt Jwt `mapstructure:"jwt"`
|
||||
OSSConfig OSSConfig `mapstructure:"oss"`
|
||||
Midtrans Midtrans `mapstructure:"midtrans"`
|
||||
Brevo Brevo `mapstructure:"brevo"`
|
||||
Email Email `mapstructure:"email"`
|
||||
}
|
||||
|
||||
var (
|
||||
@ -67,5 +69,9 @@ func (c *Config) Auth() *AuthConfig {
|
||||
jwtTokenExpiresTTL: c.Jwt.Token.ExpiresTTL,
|
||||
jwtOrderSecret: c.Jwt.TokenOrder.Secret,
|
||||
jwtOrderExpiresTTL: c.Jwt.TokenOrder.ExpiresTTL,
|
||||
jwtSecretResetPassword: JWT{
|
||||
secret: c.Jwt.TokenResetPassword.Secret,
|
||||
expireTTL: c.Jwt.TokenResetPassword.ExpiresTTL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,16 @@ package config
|
||||
import "time"
|
||||
|
||||
type AuthConfig struct {
|
||||
jwtTokenExpiresTTL int
|
||||
jwtTokenSecret string
|
||||
jwtOrderSecret string
|
||||
jwtOrderExpiresTTL int
|
||||
jwtTokenExpiresTTL int
|
||||
jwtTokenSecret string
|
||||
jwtOrderSecret string
|
||||
jwtOrderExpiresTTL int
|
||||
jwtSecretResetPassword JWT
|
||||
}
|
||||
|
||||
type JWT struct {
|
||||
secret string
|
||||
expireTTL int
|
||||
}
|
||||
|
||||
func (c *AuthConfig) AccessTokenSecret() string {
|
||||
@ -26,3 +32,12 @@ func (c *AuthConfig) AccessTokenExpiresDate() time.Time {
|
||||
duration := time.Duration(c.jwtTokenExpiresTTL)
|
||||
return time.Now().UTC().Add(time.Minute * duration)
|
||||
}
|
||||
|
||||
func (c *AuthConfig) AccessTokenResetPasswordSecret() string {
|
||||
return c.jwtSecretResetPassword.secret
|
||||
}
|
||||
|
||||
func (c *AuthConfig) AccessTokenResetPasswordExpire() time.Time {
|
||||
duration := time.Duration(c.jwtSecretResetPassword.expireTTL)
|
||||
return time.Now().UTC().Add(time.Minute * duration)
|
||||
}
|
||||
|
||||
32
config/email.go
Normal file
32
config/email.go
Normal file
@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
type Email struct {
|
||||
Sender string `mapstructure:"sender"`
|
||||
CustomReceiver string `mapstructure:"custom_receiver"`
|
||||
ResetPassword EmailConfig `mapstructure:"reset_password"`
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
Subject string `mapstructure:"subject"`
|
||||
OpeningWord string `mapstructure:"opening_word"`
|
||||
Link string `mapstructure:"link"`
|
||||
Notes string `mapstructure:"note"`
|
||||
ClosingWord string `mapstructure:"closing_word"`
|
||||
TemplateName string `mapstructure:"template_name"`
|
||||
TemplatePath string `mapstructure:"template_path"`
|
||||
}
|
||||
|
||||
type EmailMemberRequestActionConfig struct {
|
||||
TemplateName string `mapstructure:"template_name"`
|
||||
TemplatePath string `mapstructure:"template_path"`
|
||||
Subject string `mapstructure:"subject"`
|
||||
Content string `mapstructure:"content"`
|
||||
}
|
||||
|
||||
func (e *Email) GetSender() string {
|
||||
return e.Sender
|
||||
}
|
||||
|
||||
func (e *Email) GetCustomReceiver() string {
|
||||
return e.CustomReceiver
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
package config
|
||||
|
||||
type Jwt struct {
|
||||
Token Token `mapstructure:"token"`
|
||||
TokenOrder Token `mapstructure:"token-order"`
|
||||
Token Token `mapstructure:"token"`
|
||||
TokenOrder Token `mapstructure:"token-order"`
|
||||
TokenResetPassword Token `mapstructure:"token-reset-password"`
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
|
||||
5
go.mod
5
go.mod
@ -1,6 +1,6 @@
|
||||
module furtuna-be
|
||||
|
||||
go 1.19
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
@ -20,6 +20,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/antihax/optional v1.0.0 // indirect
|
||||
github.com/bytedance/sonic v1.10.2 // indirect
|
||||
github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
@ -63,6 +64,7 @@ require (
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.8.0 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/tools v0.17.0 // indirect
|
||||
@ -73,6 +75,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.50.0
|
||||
github.com/getbrevo/brevo-go v1.0.0
|
||||
github.com/veritrans/go-midtrans v0.0.0-20210616100512-16326c5eeb00
|
||||
go.uber.org/zap v1.21.0
|
||||
golang.org/x/net v0.20.0
|
||||
|
||||
8
go.sum
8
go.sum
@ -40,6 +40,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI=
|
||||
github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
@ -80,6 +82,10 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/getbrevo/brevo-go v1.0.0 h1:E/pRCsQeExvZeTCJU5vy+xHWcLaL5axWQ9QkxjlFke4=
|
||||
github.com/getbrevo/brevo-go v1.0.0/go.mod h1:2TBMEnaDqq/oiAXUYtn6eykiEdHcEoS7tc63+YoFibw=
|
||||
github.com/getbrevo/brevo-go v1.1.1 h1:6/SXEQ7ZfUjetPnJ4EncfLSUgXjQv4qUj1EQgLXnDto=
|
||||
github.com/getbrevo/brevo-go v1.1.1/go.mod h1:ExhytIoPxt/cOBl6ZEMeEZNLUKrWEYA5U3hM/8WP2bg=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
@ -400,6 +406,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
@ -35,4 +35,16 @@ oss:
|
||||
midtrans:
|
||||
server_key: "SB-Mid-server-YOIvuaIlRw3In9SymCuFz-hB"
|
||||
client_key: "SB-Mid-client-ulkZGFiS8PqBNOZz"
|
||||
env: 1
|
||||
env: 1
|
||||
|
||||
brevo:
|
||||
api_key: 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
|
||||
|
||||
email:
|
||||
sender: "siregaraditya@gmail.com"
|
||||
reset_password:
|
||||
template_name: "reset_password"
|
||||
template_path: "templates/reset_password.html"
|
||||
subject: "Reset Password"
|
||||
opening_word: "Terima kasih sudah menjadi bagian dari Furtuna. Anda telah berhasil melakukan reset password, silakan masukan unik password yang dibuat oleh sistem dibawah ini:"
|
||||
closing_word: "Silakan login kembali menggunakan email dan password anda diatas, sistem akan secara otomatis meminta anda untuk membuat password baru setelah berhasil login. Mohon maaf atas kendala yang dialami."
|
||||
@ -53,6 +53,13 @@ func NewServiceException(errType ErrType) *ServiceException {
|
||||
}
|
||||
}
|
||||
|
||||
func NewError(errType ErrType, message string) *ServiceException {
|
||||
return &ServiceException{
|
||||
errorType: errType,
|
||||
message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServiceException) ErrorType() ErrType {
|
||||
return s.errorType
|
||||
}
|
||||
@ -95,7 +102,10 @@ func (s *ServiceException) MapErrorsToCode() Code {
|
||||
case errUserIsNotFound:
|
||||
return BadRequest
|
||||
|
||||
case errInvalidLogin:
|
||||
return BadRequest
|
||||
|
||||
default:
|
||||
return ServerError
|
||||
return BadRequest
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ type UserDB struct {
|
||||
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deleted_at"`
|
||||
CreatedBy int64 `gorm:"column:created_by" json:"created_by"`
|
||||
UpdatedBy int64 `gorm:"column:updated_by" json:"updated_by"`
|
||||
ResetPassword bool `gorm:"column:reset_password" json:"reset_password"`
|
||||
}
|
||||
|
||||
func (u *UserDB) ToUser() *User {
|
||||
@ -42,18 +43,19 @@ func (u *UserDB) ToUser() *User {
|
||||
}
|
||||
|
||||
userEntity := &User{
|
||||
ID: u.ID,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
RoleID: role.Role(u.RoleID),
|
||||
RoleName: u.RoleName,
|
||||
PartnerID: u.PartnerID,
|
||||
PartnerName: u.PartnerName,
|
||||
SiteID: u.SiteID,
|
||||
SiteName: u.SiteName,
|
||||
ID: u.ID,
|
||||
Name: u.Name,
|
||||
Email: u.Email,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
RoleID: role.Role(u.RoleID),
|
||||
RoleName: u.RoleName,
|
||||
PartnerID: u.PartnerID,
|
||||
PartnerName: u.PartnerName,
|
||||
SiteID: u.SiteID,
|
||||
SiteName: u.SiteName,
|
||||
ResetPassword: u.ResetPassword,
|
||||
}
|
||||
|
||||
return userEntity
|
||||
@ -92,6 +94,7 @@ func (u *UserDB) ToUserAuthenticate(signedToken string) *AuthenticateUser {
|
||||
PartnerStatus: u.PartnerStatus,
|
||||
SiteID: u.SiteID,
|
||||
SiteName: u.SiteName,
|
||||
ResetPassword: u.ResetPassword,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
internal/entity/email.go
Normal file
13
internal/entity/email.go
Normal file
@ -0,0 +1,13 @@
|
||||
package entity
|
||||
|
||||
type (
|
||||
SendEmailNotificationParam struct {
|
||||
Sender string
|
||||
Recipient string
|
||||
CcEmails []string
|
||||
Subject string
|
||||
TemplateName string
|
||||
TemplatePath string
|
||||
Data interface{}
|
||||
}
|
||||
)
|
||||
@ -10,20 +10,21 @@ import (
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Name string
|
||||
Email string
|
||||
Password string
|
||||
Status userstatus.UserStatus
|
||||
NIK string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
RoleID role.Role
|
||||
RoleName string
|
||||
PartnerID *int64
|
||||
SiteID *int64
|
||||
SiteName string
|
||||
PartnerName string
|
||||
ID int64
|
||||
Name string
|
||||
Email string
|
||||
Password string
|
||||
Status userstatus.UserStatus
|
||||
NIK string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
RoleID role.Role
|
||||
RoleName string
|
||||
PartnerID *int64
|
||||
SiteID *int64
|
||||
SiteName string
|
||||
PartnerName string
|
||||
ResetPassword bool
|
||||
}
|
||||
|
||||
type AuthenticateUser struct {
|
||||
@ -36,6 +37,7 @@ type AuthenticateUser struct {
|
||||
PartnerStatus string
|
||||
SiteID *int64
|
||||
SiteName string
|
||||
ResetPassword bool
|
||||
}
|
||||
|
||||
type UserRoleDB struct {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"furtuna-be/internal/constants/role"
|
||||
"net/http"
|
||||
|
||||
@ -19,6 +20,8 @@ type AuthHandler struct {
|
||||
func (a *AuthHandler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) {
|
||||
authRoute := group.Group("/auth")
|
||||
authRoute.POST("/login", a.AuthLogin)
|
||||
authRoute.POST("/forgot-password", a.ForgotPassword)
|
||||
authRoute.POST("/reset-password", jwt, a.ResetPassword)
|
||||
}
|
||||
|
||||
func NewAuthHandler(service services.Auth) *AuthHandler {
|
||||
@ -77,7 +80,8 @@ func (h *AuthHandler) AuthLogin(c *gin.Context) {
|
||||
ID: int64(authUser.RoleID),
|
||||
Role: authUser.RoleName,
|
||||
},
|
||||
Site: site,
|
||||
Site: site,
|
||||
ResetPassword: authUser.ResetPassword,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.BaseResponse{
|
||||
@ -87,3 +91,72 @@ func (h *AuthHandler) AuthLogin(c *gin.Context) {
|
||||
Data: resp,
|
||||
})
|
||||
}
|
||||
|
||||
// ForgotPassword handles the request for password reset.
|
||||
// @Summary Request password reset
|
||||
// @Description Sends a password reset link to the user's email.
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bodyParam body auth2.ForgotPasswordRequest true "User email"
|
||||
// @Success 200 {object} response.BaseResponse "Password reset link sent"
|
||||
// @Failure 400 {object} response.BaseResponse{data=errors.Error} "Bad request"
|
||||
// @Router /api/v1/auth/forgot-password [post]
|
||||
// @Tags Auth Password API's
|
||||
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
||||
var bodyParam auth2.ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&bodyParam); err != nil {
|
||||
response.ErrorWrapper(c, errors.ErrorBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.SendPasswordResetLink(c, bodyParam.Email)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.BaseResponse{
|
||||
Success: true,
|
||||
Status: http.StatusOK,
|
||||
Message: "Password reset link sent",
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword handles the password reset process.
|
||||
// @Summary Reset user password
|
||||
// @Description Resets the user's password using the provided token.
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bodyParam body auth2.ResetPasswordRequest true "Reset password details"
|
||||
// @Success 200 {object} response.BaseResponse "Password reset successful"
|
||||
// @Failure 400 {object} response.BaseResponse{data=errors.Error} "Bad request"
|
||||
// @Router /api/v1/auth/reset-password [post]
|
||||
// @Tags Auth Password API's
|
||||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
ctx := auth2.GetMyContext(c)
|
||||
|
||||
var req auth2.ResetPasswordChangeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorWrapper(c, errors.ErrorBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
response.ErrorWrapper(c, errors.NewError(
|
||||
errors.ErrorBadRequest.ErrorType(),
|
||||
fmt.Sprintf("invalid request %v", err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.ResetPassword(ctx, req.OldPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.BaseResponse{
|
||||
Success: true,
|
||||
Status: http.StatusOK,
|
||||
Message: "Password reset successful",
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
@ -10,6 +16,63 @@ type ResetPasswordRequest struct {
|
||||
}
|
||||
|
||||
type ResetPasswordChangeRequest struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
OldPassword string `json:"old_password" validate:"required"`
|
||||
NewPassword string `json:"new_password" validate:"required,strongpwd"`
|
||||
}
|
||||
|
||||
func (e *ResetPasswordChangeRequest) Validate() error {
|
||||
validate := validator.New()
|
||||
|
||||
validate.RegisterValidation("strongpwd", validateStrongPassword)
|
||||
|
||||
if err := validate.Struct(e); err != nil {
|
||||
// Handle the validation errors
|
||||
for _, err := range err.(validator.ValidationErrors) {
|
||||
switch err.Field() {
|
||||
case "NewPassword":
|
||||
return fmt.Errorf("%w", validatePasswordError(err.Tag()))
|
||||
default:
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStrongPassword(fl validator.FieldLevel) bool {
|
||||
password := fl.Field().String()
|
||||
|
||||
var (
|
||||
hasMinLen = len(password) >= 8
|
||||
)
|
||||
|
||||
return hasMinLen
|
||||
}
|
||||
|
||||
// Error messages for password validation
|
||||
var (
|
||||
ErrPasswordTooShort = errors.New("password must be at least 8 characters long")
|
||||
ErrPasswordNoUpper = errors.New("password must contain at least one uppercase letter")
|
||||
ErrPasswordNoLower = errors.New("password must contain at least one lowercase letter")
|
||||
ErrPasswordNoNumber = errors.New("password must contain at least one digit")
|
||||
ErrPasswordNoSpecial = errors.New("password must contain at least one special character (!@#$%^&*)")
|
||||
ErrPasswordValidation = errors.New("password does not meet the strength requirements")
|
||||
)
|
||||
|
||||
func validatePasswordError(tag string) error {
|
||||
switch tag {
|
||||
case "min":
|
||||
return ErrPasswordTooShort
|
||||
case "uppercase":
|
||||
return ErrPasswordNoUpper
|
||||
case "lowercase":
|
||||
return ErrPasswordNoLower
|
||||
case "number":
|
||||
return ErrPasswordNoNumber
|
||||
case "special":
|
||||
return ErrPasswordNoSpecial
|
||||
default:
|
||||
return ErrPasswordValidation
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
package response
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Role Role `json:"role"`
|
||||
Partner *Partner `json:"partner"`
|
||||
Site *SiteName `json:"site,omitempty"`
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Role Role `json:"role"`
|
||||
Partner *Partner `json:"partner"`
|
||||
Site *SiteName `json:"site,omitempty"`
|
||||
ResetPassword bool `json:"reset_password"`
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
|
||||
@ -26,7 +26,8 @@ func (r *AuthRepository) CheckExistsUserAccount(ctx context.Context, email strin
|
||||
|
||||
err := r.db.
|
||||
Table("users").
|
||||
Select("users.*, user_roles.role_id, user_roles.partner_id, user_roles.site_id, sites.name, roles.role_name, partners.name as partner_name, partners.status as partner_status").
|
||||
Select("users.*, user_roles.role_id, user_roles.partner_id, user_roles.site_id,"+
|
||||
" sites.name, roles.role_name, partners.name as partner_name, partners.status as partner_status, users.reset_password").
|
||||
Where("users.email = ?", email).
|
||||
Joins("left join user_roles on users.id = user_roles.user_id").
|
||||
Joins("left join roles on user_roles.role_id = roles.role_id").
|
||||
@ -49,3 +50,49 @@ func (r *AuthRepository) CheckExistsUserAccount(ctx context.Context, email strin
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) CheckExistsUserAccountByID(ctx context.Context, userID int64) (*entity.UserDB, error) {
|
||||
var user entity.UserDB
|
||||
|
||||
err := r.db.
|
||||
Table("users").
|
||||
Select("users.*, user_roles.role_id, user_roles.partner_id, user_roles.site_id, sites.name, roles.role_name, partners.name as partner_name, partners.status as partner_status").
|
||||
Where("users.id = ?", userID).
|
||||
Joins("left join user_roles on users.id = user_roles.user_id").
|
||||
Joins("left join roles on user_roles.role_id = roles.role_id").
|
||||
Joins("left join partners on user_roles.partner_id = partners.id").
|
||||
Joins("left join sites on user_roles.site_id = sites.id").
|
||||
First(&user).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("user with not exist") // or use a custom error type
|
||||
}
|
||||
|
||||
logger.ContextLogger(ctx).Error(fmt.Sprintf("Failed to get user"), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *AuthRepository) UpdatePassword(ctx context.Context, trx *gorm.DB, newHashedPassword string, userID int64, resetPassword bool) error {
|
||||
// Perform the update using a single Updates call
|
||||
err := trx.Model(&entity.UserDB{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"password": newHashedPassword,
|
||||
"reset_password": resetPassword,
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error(fmt.Sprintf("Failed to update password for user with id: %d", userID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
88
internal/repository/brevo/init.go
Normal file
88
internal/repository/brevo/init.go
Normal file
@ -0,0 +1,88 @@
|
||||
package brevo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"furtuna-be/internal/entity"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
|
||||
brevo "github.com/getbrevo/brevo-go/lib"
|
||||
)
|
||||
|
||||
type Config interface {
|
||||
GetApiKey() string
|
||||
}
|
||||
|
||||
type ServiceImpl struct {
|
||||
brevoConn *brevo.APIClient
|
||||
}
|
||||
|
||||
func (s ServiceImpl) SendEmailTransactional(ctx context.Context, param entity.SendEmailNotificationParam) error {
|
||||
templateFile, err := ioutil.ReadFile(param.TemplatePath)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
renderedTemplate, err := template.New(param.TemplateName).Parse(string(templateFile))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.sendEmail(ctx, renderedTemplate, param)
|
||||
}
|
||||
|
||||
func (s ServiceImpl) sendEmail(ctx context.Context, tmpl *template.Template, param entity.SendEmailNotificationParam) error {
|
||||
var body bytes.Buffer
|
||||
err := tmpl.Execute(&body, param.Data)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
payload := brevo.SendSmtpEmail{
|
||||
Sender: &brevo.SendSmtpEmailSender{
|
||||
Email: "siregaraditya@gmail.com",
|
||||
},
|
||||
To: []brevo.SendSmtpEmailTo{
|
||||
{
|
||||
Email: "avranata01@gmail.com",
|
||||
},
|
||||
},
|
||||
Subject: param.Subject,
|
||||
HtmlContent: body.String(),
|
||||
}
|
||||
|
||||
if len(param.CcEmails) != 0 {
|
||||
for _, email := range param.CcEmails {
|
||||
payload.Cc = append(payload.Cc, brevo.SendSmtpEmailCc{
|
||||
Email: email,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_, _, err = s.brevoConn.TransactionalEmailsApi.SendTransacEmail(ctx, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func New(conf Config) *ServiceImpl {
|
||||
cfg := brevo.NewConfiguration()
|
||||
cfg.AddDefaultHeader("api-key", conf.GetApiKey())
|
||||
client := brevo.NewAPIClient(cfg)
|
||||
result, resp, err := client.AccountApi.GetAccount(context.Background())
|
||||
if err != nil {
|
||||
fmt.Println("Error when calling AccountApi->get_account: ", err.Error())
|
||||
log.Fatal("error")
|
||||
}
|
||||
fmt.Println("GetAccount Object:", result, " GetAccount Response: ", resp)
|
||||
|
||||
return &ServiceImpl{brevoConn: client}
|
||||
}
|
||||
@ -23,6 +23,8 @@ type CryptoConfig interface {
|
||||
AccessTokenOrderSecret() string
|
||||
AccessTokenOrderExpiresDate() time.Time
|
||||
AccessTokenExpiresDate() time.Time
|
||||
AccessTokenResetPasswordSecret() string
|
||||
AccessTokenResetPasswordExpire() time.Time
|
||||
}
|
||||
|
||||
type CryptoImpl struct {
|
||||
@ -83,6 +85,30 @@ func (c *CryptoImpl) GenerateJWT(user *entity.User) (string, error) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c *CryptoImpl) GenerateJWTReseetPassword(user *entity.User) (string, error) {
|
||||
claims := &entity.JWTAuthClaims{
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Subject: strconv.FormatInt(user.ID, 10),
|
||||
ExpiresAt: c.Config.AccessTokenResetPasswordExpire().Unix(),
|
||||
IssuedAt: time.Now().Unix(),
|
||||
NotBefore: time.Now().Unix(),
|
||||
},
|
||||
UserID: user.ID,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
token, err := jwt.
|
||||
NewWithClaims(jwt.SigningMethodHS256, claims).
|
||||
SignedString([]byte(c.Config.AccessTokenResetPasswordSecret()))
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c *CryptoImpl) ParseAndValidateJWT(tokenString string) (*entity.JWTAuthClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &entity.JWTAuthClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(c.Config.AccessTokenSecret()), nil
|
||||
@ -140,3 +166,23 @@ func (c *CryptoImpl) ValidateJWTOrder(tokenString string) (int64, int64, error)
|
||||
|
||||
return claims.PartnerID, claims.OrderID, nil
|
||||
}
|
||||
|
||||
func (c *CryptoImpl) ValidateResetPassword(tokenString string) (int64, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &entity.JWTOrderClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(c.Config.AccessTokenResetPasswordSecret()), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*entity.JWTAuthClaims)
|
||||
if !ok || !token.Valid {
|
||||
return 0, fmt.Errorf("invalid token %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return claims.UserID, nil
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"furtuna-be/internal/repository/branches"
|
||||
"furtuna-be/internal/repository/brevo"
|
||||
mdtrns "furtuna-be/internal/repository/midtrans"
|
||||
"furtuna-be/internal/repository/orders"
|
||||
"furtuna-be/internal/repository/oss"
|
||||
@ -26,45 +27,49 @@ import (
|
||||
)
|
||||
|
||||
type RepoManagerImpl struct {
|
||||
Crypto Crypto
|
||||
Auth Auth
|
||||
Event Event
|
||||
User User
|
||||
Branch Branch
|
||||
Studio Studio
|
||||
Product Product
|
||||
Order Order
|
||||
OSS OSSRepository
|
||||
Partner PartnerRepository
|
||||
Site SiteRepository
|
||||
Trx TransactionManager
|
||||
Wallet WalletRepository
|
||||
Midtrans Midtrans
|
||||
Payment Payment
|
||||
Crypto Crypto
|
||||
Auth Auth
|
||||
Event Event
|
||||
User User
|
||||
Branch Branch
|
||||
Studio Studio
|
||||
Product Product
|
||||
Order Order
|
||||
OSS OSSRepository
|
||||
Partner PartnerRepository
|
||||
Site SiteRepository
|
||||
Trx TransactionManager
|
||||
Wallet WalletRepository
|
||||
Midtrans Midtrans
|
||||
Payment Payment
|
||||
EmailService EmailService
|
||||
}
|
||||
|
||||
func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl {
|
||||
return &RepoManagerImpl{
|
||||
Crypto: crypto.NewCrypto(cfg.Auth()),
|
||||
Auth: auth.NewAuthRepository(db),
|
||||
Event: event.NewEventRepo(db),
|
||||
User: users.NewUserRepository(db),
|
||||
Branch: branches.NewBranchRepository(db),
|
||||
Studio: studios.NewStudioRepository(db),
|
||||
Product: products.NewProductRepository(db),
|
||||
Order: orders.NewOrderRepository(db),
|
||||
OSS: oss.NewOssRepositoryImpl(cfg.OSSConfig),
|
||||
Partner: partners.NewPartnerRepository(db),
|
||||
Site: sites.NewSiteRepository(db),
|
||||
Trx: trx.NewGormTransactionManager(db),
|
||||
Wallet: repository.NewWalletRepository(db),
|
||||
Midtrans: mdtrns.New(&cfg.Midtrans),
|
||||
Payment: payment.NewPaymentRepository(db),
|
||||
Crypto: crypto.NewCrypto(cfg.Auth()),
|
||||
Auth: auth.NewAuthRepository(db),
|
||||
Event: event.NewEventRepo(db),
|
||||
User: users.NewUserRepository(db),
|
||||
Branch: branches.NewBranchRepository(db),
|
||||
Studio: studios.NewStudioRepository(db),
|
||||
Product: products.NewProductRepository(db),
|
||||
Order: orders.NewOrderRepository(db),
|
||||
OSS: oss.NewOssRepositoryImpl(cfg.OSSConfig),
|
||||
Partner: partners.NewPartnerRepository(db),
|
||||
Site: sites.NewSiteRepository(db),
|
||||
Trx: trx.NewGormTransactionManager(db),
|
||||
Wallet: repository.NewWalletRepository(db),
|
||||
Midtrans: mdtrns.New(&cfg.Midtrans),
|
||||
Payment: payment.NewPaymentRepository(db),
|
||||
EmailService: brevo.New(&cfg.Brevo),
|
||||
}
|
||||
}
|
||||
|
||||
type Auth interface {
|
||||
CheckExistsUserAccount(ctx context.Context, email string) (*entity.UserDB, error)
|
||||
CheckExistsUserAccountByID(ctx context.Context, userID int64) (*entity.UserDB, error)
|
||||
UpdatePassword(ctx context.Context, trx *gorm.DB, newHashedPassword string, userID int64, resetPassword bool) error
|
||||
}
|
||||
|
||||
type Event interface {
|
||||
@ -79,8 +84,10 @@ type Crypto interface {
|
||||
CompareHashAndPassword(hash string, password string) bool
|
||||
ValidateWT(tokenString string) (*jwt.Token, error)
|
||||
GenerateJWT(user *entity.User) (string, error)
|
||||
GenerateJWTReseetPassword(user *entity.User) (string, error)
|
||||
GenerateJWTOrder(order *entity.Order) (string, error)
|
||||
ValidateJWTOrder(tokenString string) (int64, int64, error)
|
||||
ValidateResetPassword(tokenString string) (int64, error)
|
||||
ParseAndValidateJWT(token string) (*entity.JWTAuthClaims, error)
|
||||
}
|
||||
|
||||
@ -171,3 +178,7 @@ type Payment interface {
|
||||
FindByOrderAndPartnerID(ctx context.Context, orderID, partnerID int64) (*entity.Payment, error)
|
||||
FindByReferenceID(ctx context.Context, db *gorm.DB, referenceID string) (*entity.Payment, error)
|
||||
}
|
||||
|
||||
type EmailService interface {
|
||||
SendEmailTransactional(ctx context.Context, param entity.SendEmailNotificationParam) error
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"furtuna-be/config"
|
||||
"furtuna-be/internal/common/mycontext"
|
||||
"furtuna-be/internal/entity"
|
||||
"furtuna-be/internal/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@ -14,12 +17,23 @@ import (
|
||||
type AuthServiceImpl struct {
|
||||
authRepo repository.Auth
|
||||
crypto repository.Crypto
|
||||
user repository.User
|
||||
emailSvc repository.EmailService
|
||||
emailCfg config.Email
|
||||
trxRepo repository.TransactionManager
|
||||
}
|
||||
|
||||
func New(authRepo repository.Auth, crypto repository.Crypto) *AuthServiceImpl {
|
||||
func New(authRepo repository.Auth,
|
||||
crypto repository.Crypto, user repository.User, emailSvc repository.EmailService,
|
||||
emailCfg config.Email, trxRepo repository.TransactionManager,
|
||||
) *AuthServiceImpl {
|
||||
return &AuthServiceImpl{
|
||||
authRepo: authRepo,
|
||||
crypto: crypto,
|
||||
user: user,
|
||||
emailSvc: emailSvc,
|
||||
emailCfg: emailCfg,
|
||||
trxRepo: trxRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,3 +60,115 @@ func (u *AuthServiceImpl) AuthenticateUser(ctx context.Context, email, password
|
||||
|
||||
return user.ToUserAuthenticate(signedToken), nil
|
||||
}
|
||||
|
||||
func (u *AuthServiceImpl) SendPasswordResetLink(ctx context.Context, email string) error {
|
||||
// Check if the user exists
|
||||
user, err := u.authRepo.CheckExistsUserAccount(ctx, email)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("error when getting user", zap.Error(err))
|
||||
return errors.ErrorInternalServer
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return errors.ErrorUserIsNotFound
|
||||
}
|
||||
|
||||
// Begin a transaction
|
||||
trx, err := u.trxRepo.Begin(ctx)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("error when beginning transaction", zap.Error(err))
|
||||
return errors.ErrorInternalServer
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
u.trxRepo.Rollback(trx)
|
||||
logger.ContextLogger(ctx).Error("panic recovered in SendPasswordResetLink", zap.Any("recover", r))
|
||||
err = errors.ErrorInternalServer
|
||||
}
|
||||
}()
|
||||
|
||||
// Generate a new password
|
||||
generatedPassword := utils.GenerateRandomString(10)
|
||||
hashPassword, err := user.ToUser().HashedPassword(generatedPassword)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("error when generating hashed password", zap.Error(err))
|
||||
u.trxRepo.Rollback(trx)
|
||||
return errors.ErrorInternalServer
|
||||
}
|
||||
|
||||
// Update the user's password in the database
|
||||
if err := u.authRepo.UpdatePassword(ctx, trx, hashPassword, user.ID, true); err != nil {
|
||||
logger.ContextLogger(ctx).Error("error when updating user password", zap.Error(err))
|
||||
u.trxRepo.Rollback(trx)
|
||||
return errors.ErrorInternalServer
|
||||
}
|
||||
|
||||
// If a custom receiver is specified, override the email
|
||||
if u.emailCfg.CustomReceiver != "" {
|
||||
email = u.emailCfg.CustomReceiver
|
||||
}
|
||||
|
||||
// Prepare the email notification parameters
|
||||
renewPasswordRequest := entity.SendEmailNotificationParam{
|
||||
Recipient: email,
|
||||
Subject: u.emailCfg.ResetPassword.Subject,
|
||||
TemplateName: u.emailCfg.ResetPassword.TemplateName,
|
||||
TemplatePath: u.emailCfg.ResetPassword.TemplatePath,
|
||||
Data: map[string]interface{}{
|
||||
"Name": user.Name,
|
||||
"OpeningWord": u.emailCfg.ResetPassword.OpeningWord,
|
||||
"Password": generatedPassword,
|
||||
"ClosingWord": u.emailCfg.ResetPassword.ClosingWord,
|
||||
"Note": u.emailCfg.ResetPassword.Notes,
|
||||
"Link": u.emailCfg.ResetPassword.Link,
|
||||
},
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
u.trxRepo.Rollback(trx)
|
||||
logger.ContextLogger(ctx).Error("panic recovered in SendPasswordResetLink", zap.Any("recover", r))
|
||||
err = errors.ErrorInternalServer
|
||||
}
|
||||
}()
|
||||
|
||||
// Send the email notification
|
||||
err = u.emailSvc.SendEmailTransactional(ctx, renewPasswordRequest)
|
||||
if err != nil {
|
||||
u.trxRepo.Rollback(trx)
|
||||
logger.ContextLogger(ctx).Error("error when sending password reset email", zap.Error(err))
|
||||
return errors.ErrorInternalServer
|
||||
}
|
||||
|
||||
trx.Commit()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *AuthServiceImpl) ResetPassword(ctx mycontext.Context, oldPassword, newPassword string) error {
|
||||
user, err := u.authRepo.CheckExistsUserAccountByID(ctx, ctx.RequestedBy())
|
||||
if err != nil {
|
||||
return errors.ErrorInvalidRequest
|
||||
}
|
||||
|
||||
if ok := u.crypto.CompareHashAndPassword(user.Password, oldPassword); !ok {
|
||||
return errors.ErrorUserInvalidLogin
|
||||
}
|
||||
|
||||
password, err := user.ToUser().HashedPassword(newPassword)
|
||||
if err != nil {
|
||||
return errors.ErrorInvalidRequest
|
||||
}
|
||||
|
||||
trx, _ := u.trxRepo.Begin(ctx)
|
||||
|
||||
err = u.authRepo.UpdatePassword(ctx, trx, password, user.ID, false)
|
||||
if err != nil {
|
||||
return errors.ErrorInternalServer
|
||||
}
|
||||
|
||||
trx.Commit()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ type ServiceManagerImpl struct {
|
||||
|
||||
func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl) *ServiceManagerImpl {
|
||||
return &ServiceManagerImpl{
|
||||
AuthSvc: auth.New(repo.Auth, repo.Crypto),
|
||||
AuthSvc: auth.New(repo.Auth, repo.Crypto, repo.User, repo.EmailService, cfg.Email, repo.Trx),
|
||||
EventSvc: event.NewEventService(repo.Event),
|
||||
UserSvc: users.NewUserService(repo.User, repo.Branch),
|
||||
BranchSvc: branch.NewBranchService(repo.Branch),
|
||||
@ -52,6 +52,8 @@ func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl)
|
||||
|
||||
type Auth interface {
|
||||
AuthenticateUser(ctx context.Context, email, password string) (*entity.AuthenticateUser, error)
|
||||
SendPasswordResetLink(ctx context.Context, email string) error
|
||||
ResetPassword(ctx mycontext.Context, oldPassword, newPassword string) error
|
||||
}
|
||||
|
||||
type Event interface {
|
||||
|
||||
260
templates/reset_password.html
Normal file
260
templates/reset_password.html
Normal file
@ -0,0 +1,260 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media (max-width: 480px) {
|
||||
.bodyStyle {
|
||||
padding: 0px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#f1f0f7;">
|
||||
<div class="bodyStyle" style="padding: 50px; background-color: #f1f0f7;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:20px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;line-height:25px;text-align:left;color:#0a2062;">Halo {{ .Name }},</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:25px;text-align:left;color:#000000;">{{ .OpeningWord }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:25px;text-align:left;color:#000000;">Password: <b style="font-size:19px">{{ .Password }}</b></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:25px;text-align:left;color:#000000;">{{ .ClosingWord }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:25px;text-align:left;color:#000000;">Terima kasih</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<p style="border-top:solid 1px #808080;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #808080;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:12px;line-height:15px;text-align:left;color:#808080;">Email ini tergenerate oleh sistem secara otomatis, anda tidak perlu membalas email ini.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user