diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go index a217efc..58b8ed5 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -29,6 +29,7 @@ func (app *application) routes() http.Handler { router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) router.HandlerFunc(http.MethodPost, "/v1/tokens/password-reset", app.createPasswordResetTokenHandler) + router.HandlerFunc(http.MethodPost, "/v1/tokens/activation", app.createActivationTokenHandler) router.Handler(http.MethodGet, "/debug/vars", expvar.Handler()) diff --git a/projects/greenlight/cmd/api/tokens.go b/projects/greenlight/cmd/api/tokens.go index 0d698e0..4596f04 100644 --- a/projects/greenlight/cmd/api/tokens.go +++ b/projects/greenlight/cmd/api/tokens.go @@ -148,3 +148,73 @@ func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r app.serverErrorResponse(w, r, err) } } + +func (app *application) createActivationTokenHandler(w http.ResponseWriter, r *http.Request) { + // Parse and validate the user's email address. + var input struct { + Email string `json:"email"` + } + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validator.New() + + if data.ValidateEmail(v, input.Email); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + // Try to retrieve the corresponding user record for the email address. If it can't + // be found, return an error message to the client. + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, data.ErrRecordNotFound): + v.AddError("email", "no matching email address found") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + // Return an error if the user has already been activated. + if user.Activated { + v.AddError("email", "user has already been activated") + app.failedValidationResponse(w, r, v.Errors) + return + } + + // Otherwise, create a new activation token. + token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, data.ScopeActivation) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + // Email the user with their additional activation token. + app.background(func() { + data := map[string]interface{}{ + "activationToken": token.Plaintext, + } + + // Since email addresses MAY be case sensitive, notice that we are sending this + // email using the address stored in our database for the user --- not to the + // input.Email address provided by the client in this request. + err = app.mailer.Send(user.Email, "token_activation.tmpl", data) + if err != nil { + app.logger.PrintError(err, nil) + } + }) + + // Send a 202 Accepted response and confirmation message to the client. + env := envelope{"message": "an email will be sent to you containing activation instructions"} + + err = app.writeJSON(w, http.StatusAccepted, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml b/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml index 777af9e..6e20e4e 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Authentication/Authenticate.yml @@ -11,7 +11,11 @@ http: data: |- { "email": "alice@example.com", - "password": "pa55word" + "password": "pa44word" + + + + } auth: inherit diff --git a/projects/greenlight/docs/api/greenlight-bruno/Tokens/Activation.yml b/projects/greenlight/docs/api/greenlight-bruno/Tokens/Activation.yml new file mode 100644 index 0000000..505b670 --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Tokens/Activation.yml @@ -0,0 +1,21 @@ +info: + name: Activation + type: http + seq: 2 + +http: + method: POST + url: "{{URL}}/v1/tokens/activation" + body: + type: json + data: |- + { + "email": "alice@example.com" + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/projects/greenlight/docs/api/greenlight-bruno/Tokens/Password Reset.yml b/projects/greenlight/docs/api/greenlight-bruno/Tokens/Password Reset.yml new file mode 100644 index 0000000..6a90551 --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Tokens/Password Reset.yml @@ -0,0 +1,22 @@ +info: + name: Password Reset + type: http + seq: 2 + +http: + method: POST + url: "{{URL}}/v1/tokens/password-reset" + body: + type: json + data: |+ + { + "email": "alice@example.com" + } + + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/projects/greenlight/docs/api/greenlight-bruno/Tokens/folder.yml b/projects/greenlight/docs/api/greenlight-bruno/Tokens/folder.yml new file mode 100644 index 0000000..8e7637b --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Tokens/folder.yml @@ -0,0 +1,7 @@ +info: + name: Tokens + type: folder + seq: 5 + +request: + auth: inherit diff --git a/projects/greenlight/docs/api/greenlight-bruno/Users/Update Password.yml b/projects/greenlight/docs/api/greenlight-bruno/Users/Update Password.yml new file mode 100644 index 0000000..7b13c90 --- /dev/null +++ b/projects/greenlight/docs/api/greenlight-bruno/Users/Update Password.yml @@ -0,0 +1,23 @@ +info: + name: Update Password + type: http + seq: 3 + +http: + method: PUT + url: "{{URL}}/v1/users/password" + body: + type: json + data: |- + { + "token": "3JWV7KEAYAMEFQMONXKVNIFYSY", + "password": "pa44word" + + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/projects/greenlight/internal/mailer/templates/token_activation.tmpl b/projects/greenlight/internal/mailer/templates/token_activation.tmpl new file mode 100644 index 0000000..6b30390 --- /dev/null +++ b/projects/greenlight/internal/mailer/templates/token_activation.tmpl @@ -0,0 +1,36 @@ +{{define "subject"}}Activate your Greenlight account{{end}} + +{{define "plainBody"}} +Hi, + +Please send a `PUT /v1/users/activated` request with the following JSON body to activate your account: + +{"token": "{{.activationToken}}"} + +Please note that this is a one-time use token and it will expire in 3 days. + +Thanks, + +The Greenlight Team +{{end}} + +{{define "htmlBody"}} + + +
+ + + + +Hi,
+Please send a PUT /v1/users/activated request with the following JSON body to activate your account:
+ {"token": "{{.activationToken}}"}
+
+ Please note that this is a one-time use token and it will expire in 3 days.
+Thanks,
+The Greenlight Team
+ + +{{end}} +