auto mail generation
This commit is contained in:
210
controllers/resendInvite_test.go
Normal file
210
controllers/resendInvite_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/middleware"
|
||||
"nearle/services"
|
||||
"nearle/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
/*
|
||||
Who may re-issue a first-password link, and for whom.
|
||||
|
||||
This endpoint mints a credential, so most of what matters is what it refuses.
|
||||
The service layer refuses the business cases — an account that already has a
|
||||
password, a tenant whose primary email matches no login — and those are covered
|
||||
in `services/resendInvite_test.go`. This file is about the door: who gets
|
||||
through it, and which account a request actually names.
|
||||
*/
|
||||
|
||||
// resendService answers both resends and records which was called. Only the two
|
||||
// methods under test are real; the rest of TenantService is embedded nil, which
|
||||
// panics if anything else is reached — exactly the signal wanted.
|
||||
type resendService struct {
|
||||
services.TenantService
|
||||
byTenant int
|
||||
byUser int
|
||||
outcome services.InviteOutcome
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *resendService) ResendInvite(tenantID int) (services.InviteOutcome, error) {
|
||||
s.byTenant = tenantID
|
||||
return s.outcome, s.err
|
||||
}
|
||||
|
||||
func (s *resendService) ResendInviteToUser(userID int) (services.InviteOutcome, error) {
|
||||
s.byUser = userID
|
||||
return s.outcome, s.err
|
||||
}
|
||||
|
||||
func resendApp(t *testing.T, service *resendService) *fiber.App {
|
||||
t.Helper()
|
||||
t.Setenv("POS_TOKEN_SECRET", testSecret)
|
||||
|
||||
app := fiber.New()
|
||||
// The real guard, mounted as routes.go mounts it: this endpoint sits behind
|
||||
// the session, and the handler then requires a platform account on top.
|
||||
app.Use("/live/api/v1/web", middleware.WebAuth(nil))
|
||||
app.Post("/live/api/v1/web/tenants/resendinvite", NewTenantController(service).ResendInvite)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// staffToken is a signed session for a Nearle staff account.
|
||||
//
|
||||
// `Superadmin` is the signal, and it is minted from `app_users.issuperadmin` —
|
||||
// not from the tenant being zero and not from a role id. Both of those look
|
||||
// equivalent and are not: `app_roles` calls roleid 1 "Super admin" and
|
||||
// onboarding wrote 1 for every shop owner, and a zero tenant is what an
|
||||
// unfilled column looks like. See `utils.WebClaims`.
|
||||
func staffToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
token, _, err := utils.MintWebToken(utils.WebClaims{
|
||||
Userid: 12, Roleid: 1, Configid: 1, Superadmin: true,
|
||||
}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// merchantToken is a signed session for a shop's own admin.
|
||||
func merchantToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
token, _, err := utils.MintWebToken(utils.WebClaims{
|
||||
Userid: 904, Tenantid: 1147, Roleid: 3, Configid: 1,
|
||||
}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func postAs(t *testing.T, app *fiber.App, token, body string) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest("POST", "/live/api/v1/web/tenants/resendinvite",
|
||||
strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := app.Test(req, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("resendinvite: %v", err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(raw)
|
||||
}
|
||||
|
||||
func TestAMerchantCannotResendAnything(t *testing.T) {
|
||||
// A merchant's session is pinned to their own tenant, so the worst they could
|
||||
// do is re-invite themselves — and the service refuses that, because an
|
||||
// account signing in to ask already has a password. Refusing here as well
|
||||
// means the endpoint does not rely on two other checks to make the wrong case
|
||||
// impossible.
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, merchantToken(t), `{"tenantid":1147}`)
|
||||
|
||||
if status != 403 {
|
||||
t.Fatalf("a merchant was let through: %d %s", status, body)
|
||||
}
|
||||
if service.byTenant != 0 || service.byUser != 0 {
|
||||
t.Fatal("the service was reached by a caller who should have been refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearleStaffCanResendToATenantsOwner(t *testing.T) {
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
|
||||
|
||||
if status != 200 {
|
||||
t.Fatalf("refused Nearle staff: %d %s", status, body)
|
||||
}
|
||||
if service.byTenant != 1147 {
|
||||
t.Fatalf("resent for tenant %d, want 1147", service.byTenant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUseridNamesOnePersonRatherThanTheOwner(t *testing.T) {
|
||||
// The reason this parameter exists. Staff added after onboarding, and the
|
||||
// login every branch spawns, are created with no password too — and a
|
||||
// business has many of them, so "the tenant's invitation" cannot reach them.
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, staffToken(t), `{"userid":7781}`)
|
||||
|
||||
if status != 200 {
|
||||
t.Fatalf("refused: %d %s", status, body)
|
||||
}
|
||||
if service.byUser != 7781 {
|
||||
t.Fatalf("resent for user %d, want 7781", service.byUser)
|
||||
}
|
||||
if service.byTenant != 0 {
|
||||
t.Fatal("emailed the owner when a person was named")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUseridWinsOverATenantid(t *testing.T) {
|
||||
// A caller that sent a person's id meant that person. Falling back to the
|
||||
// owner would be the wrong mailbox with nothing on the response to say so.
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
if status, body := postAs(t, app, staffToken(t), `{"tenantid":1147,"userid":7781}`); status != 200 {
|
||||
t.Fatalf("refused: %d %s", status, body)
|
||||
}
|
||||
if service.byUser != 7781 || service.byTenant != 0 {
|
||||
t.Fatalf("resolved to the wrong account: user=%d tenant=%d", service.byUser, service.byTenant)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyBodyIsRefusedRatherThanSentToTenantZero(t *testing.T) {
|
||||
// `{}` parses cleanly into two zeroes. Without this check it would reach the
|
||||
// service as tenant 0 and come back "tenant 0 has no account matching its
|
||||
// primary email address", which describes nothing the caller did.
|
||||
service := &resendService{outcome: services.InviteOutcome{Sent: true}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, staffToken(t), `{}`)
|
||||
|
||||
if status != 400 {
|
||||
t.Fatalf("an empty request was accepted: %d %s", status, body)
|
||||
}
|
||||
if service.byTenant != 0 || service.byUser != 0 {
|
||||
t.Fatal("the service was called with nothing to act on")
|
||||
}
|
||||
if !strings.Contains(body, "tenantid") || !strings.Contains(body, "userid") {
|
||||
t.Errorf("the refusal does not say what to send: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailThatDidNotLeaveIsReportedAsAFailure(t *testing.T) {
|
||||
// The operator pressed a button expecting an email to go. "Success" with no
|
||||
// mail sent is the one answer they cannot act on.
|
||||
service := &resendService{outcome: services.InviteOutcome{
|
||||
Sent: false, Reason: "MAIL_HOST is not set",
|
||||
}}
|
||||
app := resendApp(t, service)
|
||||
|
||||
status, body := postAs(t, app, staffToken(t), `{"tenantid":1147}`)
|
||||
|
||||
if status != 409 {
|
||||
t.Fatalf("an unsent invitation was reported as sent: %d %s", status, body)
|
||||
}
|
||||
if !strings.Contains(body, "MAIL_HOST") {
|
||||
t.Errorf("the reason was lost: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nearle/middleware"
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"nearle/utils"
|
||||
|
||||
fiberv1 "github.com/gofiber/fiber"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -38,11 +41,13 @@ session, and it must refuse everything except the one case it exists for.
|
||||
type fakePasswords struct {
|
||||
// set records what reached the write, so a refusal can be shown to have
|
||||
// refused rather than merely reported.
|
||||
set []string
|
||||
refuseIt error
|
||||
set []string
|
||||
lastUserid int
|
||||
refuseIt error
|
||||
}
|
||||
|
||||
func (f *fakePasswords) SetInitialPassword(userid int, password string) error {
|
||||
f.lastUserid = userid
|
||||
if f.refuseIt != nil {
|
||||
return f.refuseIt
|
||||
}
|
||||
@@ -65,8 +70,8 @@ func (f *fakePasswords) UpdateStaff(models.User) error { return nil }
|
||||
func (f *fakePasswords) AppLogin(models.User) (models.TenantUserInfo, fiberv1.Map, error) {
|
||||
return models.TenantUserInfo{}, fiberv1.Map{}, nil
|
||||
}
|
||||
func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, error) {
|
||||
return models.UserInfo{}, nil
|
||||
func (f *fakePasswords) CreateUser(models.User) (models.UserInfo, services.InviteOutcome, error) {
|
||||
return models.UserInfo{}, services.InviteOutcome{}, nil
|
||||
}
|
||||
|
||||
func passwordApp(t *testing.T, service *fakePasswords) *fiber.App {
|
||||
@@ -103,7 +108,7 @@ func TestAFirstPasswordCanBeSetWithoutASession(t *testing.T) {
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"userid":904,"password":"opensesame"}`)
|
||||
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
if status == fiber.StatusUnauthorized {
|
||||
t.Fatalf("the guard blocked the one call that cannot present a token: %s", body)
|
||||
@@ -139,7 +144,7 @@ func TestAnAccountThatAlreadyHasOneIsRefusedAsAConflict(t *testing.T) {
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"userid":904,"password":"opensesame"}`)
|
||||
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
if status != fiber.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d: %s", status, body)
|
||||
@@ -157,7 +162,7 @@ func TestTheRefusalDoesNotSayWhichAccountsExist(t *testing.T) {
|
||||
app := passwordApp(t, service)
|
||||
|
||||
_, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"userid":904,"password":"opensesame"}`)
|
||||
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
for _, leak := range []string{"not found", "no such", "does not exist"} {
|
||||
if strings.Contains(strings.ToLower(body), leak) {
|
||||
@@ -179,7 +184,7 @@ func TestTheAnswerIsTheEnvelopeTheConsoleUnwraps(t *testing.T) {
|
||||
// A handler answering at the top level passes a service test and hands the
|
||||
// console `undefined`.
|
||||
_, body := send(t, passwordApp(t, &fakePasswords{}), "POST",
|
||||
"/live/api/v1/web/users/setpassword", `{"userid":904,"password":"opensesame"}`)
|
||||
"/live/api/v1/web/users/setpassword", `{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
var envelope struct {
|
||||
Status bool `json:"status"`
|
||||
@@ -204,3 +209,94 @@ func (f *fakePasswords) TenantWebLogin(models.User) (models.TenantUserInfo, map[
|
||||
return models.TenantUserInfo{}, map[string]interface{}{}
|
||||
}
|
||||
func (f *fakePasswords) DeleteUser(int) error { return nil }
|
||||
|
||||
// invite mints a real invitation for the test's account.
|
||||
//
|
||||
// A helper rather than a literal, because the token is signed: a hand-written
|
||||
// string would test the refusal path and nothing else, and the point of these
|
||||
// is what happens when a genuine invitation arrives.
|
||||
func invite(t *testing.T, userid int) string {
|
||||
t.Helper()
|
||||
token, _, err := utils.MintInviteToken(utils.InviteClaims{Userid: userid, Tenantid: 1147}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("minting an invitation: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/*
|
||||
The invitation replaced a userid, and that was a security fix rather than a
|
||||
tidy-up.
|
||||
|
||||
`applogin` answers a POST carrying an email and no password with 409 and the
|
||||
userid, for any account that has not set one. So the recipe was: know a
|
||||
merchant's primary email — usually printed on their shopfront — POST it, receive
|
||||
their userid, set their password, own the business's admin account. No guessing
|
||||
at any step, and the empty-password check was no defence because an un-set-up
|
||||
account is exactly what such an attacker wants.
|
||||
*/
|
||||
|
||||
func TestAUseridIsNoLongerEnoughToSetAPassword(t *testing.T) {
|
||||
// The hole, asserted closed. A body carrying a userid and no invitation
|
||||
// must not set anything, whatever the userid is.
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"userid":904,"password":"opensesame"}`)
|
||||
|
||||
if status == fiber.StatusOK {
|
||||
t.Fatalf("a bare userid still set a password: %s", body)
|
||||
}
|
||||
if len(service.set) != 0 {
|
||||
t.Fatalf("a bare userid reached the service: %v", service.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInvitationSetsThePasswordForTheAccountItNames(t *testing.T) {
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, body := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+invite(t, 904)+`","password":"opensesame"}`)
|
||||
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("HTTP %d: %s", status, body)
|
||||
}
|
||||
if len(service.set) != 1 || service.set[0] != "opensesame" {
|
||||
t.Fatalf("the password did not reach the service: %v", service.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUseridComesFromTheSignatureNotTheRequest(t *testing.T) {
|
||||
// An invitation for 904 with a `userid` field claiming 999 must set 904's
|
||||
// password. If the body could override it, the token would be decoration.
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+invite(t, 904)+`","userid":999,"password":"opensesame"}`)
|
||||
|
||||
if status != fiber.StatusOK {
|
||||
t.Fatalf("a valid invitation was refused: %d", status)
|
||||
}
|
||||
if service.lastUserid != 904 {
|
||||
t.Fatalf("the request's userid won: set the password for %d", service.lastUserid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAForgedInvitationIsRefused(t *testing.T) {
|
||||
service := &fakePasswords{}
|
||||
app := passwordApp(t, service)
|
||||
|
||||
for _, token := range []string{"", "i1.forged.signature", "not-a-token", "w1.a.b"} {
|
||||
status, _ := send(t, app, "POST", "/live/api/v1/web/users/setpassword",
|
||||
`{"token":"`+token+`","password":"opensesame"}`)
|
||||
if status == fiber.StatusOK {
|
||||
t.Fatalf("%q was accepted as an invitation", token)
|
||||
}
|
||||
}
|
||||
if len(service.set) != 0 {
|
||||
t.Fatalf("a forged invitation wrote: %v", service.set)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package controllers
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"nearle/middleware"
|
||||
"nearle/models"
|
||||
"nearle/services"
|
||||
"net/http"
|
||||
@@ -346,7 +347,8 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.tenantService.CreateStaff(data); err != nil {
|
||||
invite, err := ctl.tenantService.CreateStaff(data)
|
||||
if err != nil {
|
||||
// A rejected PIN, a missing name, a role nobody set — these are things
|
||||
// the person filling in the form can fix, so they come back as 400 with
|
||||
// the reason. This answered 500 with a body claiming 409, which told a
|
||||
@@ -358,10 +360,17 @@ func (ctl *TenantController) CreateStaff(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// The person was hired either way. Whether they were emailed their
|
||||
// first-password link is reported beside that rather than folded into
|
||||
// `status`: this account is created with no password and the link is the only
|
||||
// way in, so an operator who is not told cannot know they have added somebody
|
||||
// who cannot sign in.
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusCreated,
|
||||
"message": "Staff created successfully",
|
||||
"status": true,
|
||||
"code": http.StatusCreated,
|
||||
"message": "Staff created successfully",
|
||||
"status": true,
|
||||
"invited": invite.Sent,
|
||||
"invitereason": invite.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -436,7 +445,7 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
result, err := ctl.tenantService.CreateTenantUser(data)
|
||||
result, invite, err := ctl.tenantService.CreateTenantUser(data)
|
||||
if err != nil {
|
||||
if err.Error() == "Tenant Already Exists" {
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
@@ -453,11 +462,21 @@ func (ctl *TenantController) CreateTenantUser(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// The tenant was created either way. The invitation is reported beside it
|
||||
// rather than folded into `status`, because a merchant who exists and has
|
||||
// not been emailed is a task for the operator — resend, or correct the
|
||||
// address — and not a failed onboarding to be retried.
|
||||
//
|
||||
// `invited: false` with a reason is the state the platform console shows on
|
||||
// the tenant, so it never has to guess whether the email went.
|
||||
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||
"code": 201,
|
||||
"status": true,
|
||||
"message": "Successfully Created",
|
||||
"details": result,
|
||||
"invited": invite.Sent,
|
||||
// Omitted when it sent, so a successful onboarding carries no apology.
|
||||
"invitereason": invite.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -776,3 +795,79 @@ func (ctl *TenantController) AssignPartner(c *fiber.Ctx) error {
|
||||
"code": http.StatusOK, "status": true, "message": "Successfully Updated",
|
||||
})
|
||||
}
|
||||
|
||||
// ResendInvite re-issues a merchant's first-password link.
|
||||
//
|
||||
// ── Why this is platform staff only ─────────────────────────────────────────
|
||||
//
|
||||
// It mints a credential. `middleware.WebAuth` already pins a merchant's session
|
||||
// to their own tenant, so a shop could at most re-invite itself — but the
|
||||
// account it would be inviting is the one signing in to ask, which can only
|
||||
// happen if that account already has a password, and the service refuses that
|
||||
// case outright.
|
||||
//
|
||||
// So the only caller this is for is Nearle's own staff, chasing a merchant who
|
||||
// never received the mail. Saying so explicitly is better than relying on two
|
||||
// other checks to make the wrong case impossible.
|
||||
func (ctl *TenantController) ResendInvite(c *fiber.Ctx) error {
|
||||
claims, ok := middleware.WebClaimsFrom(c)
|
||||
if !ok || !claims.IsPlatformAccount() {
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"code": http.StatusForbidden, "status": false,
|
||||
"message": "Only Nearle staff can resend an invitation.",
|
||||
})
|
||||
}
|
||||
|
||||
// Either a tenant — meaning its owner, the one account onboarding created —
|
||||
// or one named person. Staff added later and the login every branch spawns
|
||||
// are created with no password too, and a business has many of them, so
|
||||
// "the tenant's invitation" cannot reach them.
|
||||
var req struct {
|
||||
Tenantid int `json:"tenantid"`
|
||||
Userid int `json:"userid"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false, "message": "Invalid request body",
|
||||
})
|
||||
}
|
||||
if req.Tenantid <= 0 && req.Userid <= 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"code": http.StatusBadRequest, "status": false,
|
||||
"message": "Send a tenantid to re-invite the owner, or a userid to re-invite one person.",
|
||||
})
|
||||
}
|
||||
|
||||
// `userid` wins when both arrive. It is the more specific of the two, and a
|
||||
// caller that sent a person's id meant that person — silently emailing the
|
||||
// owner instead would be the wrong mailbox with no sign anything was off.
|
||||
var (
|
||||
outcome services.InviteOutcome
|
||||
err error
|
||||
)
|
||||
if req.Userid > 0 {
|
||||
outcome, err = ctl.tenantService.ResendInviteToUser(req.Userid)
|
||||
} else {
|
||||
outcome, err = ctl.tenantService.ResendInvite(req.Tenantid)
|
||||
}
|
||||
if err != nil {
|
||||
// 409, not 500. Every failure here is a business fact the operator can
|
||||
// act on — no such tenant, an address that matches no login, a merchant
|
||||
// already set up — rather than a fault in the server.
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict, "status": false, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
if !outcome.Sent {
|
||||
// The tenant is fine and the mail did not go. Reported as a failure
|
||||
// because the operator pressed a button expecting an email to leave,
|
||||
// and the reason names what to fix.
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict, "status": false, "message": outcome.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"code": http.StatusOK, "status": true, "message": "Invitation sent.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// Call service
|
||||
info, err := ctl.userService.CreateUser(user)
|
||||
info, invite, err := ctl.userService.CreateUser(user)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"code": http.StatusConflict,
|
||||
@@ -264,11 +264,17 @@ func (ctl *UserController) CreateUser(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// The account was created either way. Whether its first-password invitation
|
||||
// was emailed is reported beside it rather than folded into `status`: the
|
||||
// account has no password and the link is the only way to set one, so an
|
||||
// operator who is not told has hired somebody who cannot sign in.
|
||||
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||
"code": http.StatusCreated,
|
||||
"status": true,
|
||||
"message": "Success",
|
||||
"details": info,
|
||||
"code": http.StatusCreated,
|
||||
"status": true,
|
||||
"message": "Success",
|
||||
"details": info,
|
||||
"invited": invite.Sent,
|
||||
"invitereason": invite.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -347,7 +353,9 @@ func (ctl *UserController) DeleteUser(c *fiber.Ctx) error {
|
||||
// what makes it safe to leave open. See the repository for the rest.
|
||||
func (ctl *UserController) SetPassword(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Userid int `json:"userid"`
|
||||
// The invitation, exactly as it arrived in the emailed link. The userid
|
||||
// is read out of the signature and never out of the request — see below.
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
@@ -356,7 +364,27 @@ func (ctl *UserController) SetPassword(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.userService.SetInitialPassword(req.Userid, req.Password); err != nil {
|
||||
// ── Why this takes a token and no longer takes a userid ─────────────────
|
||||
//
|
||||
// It used to accept `{userid, password}`, and that was an account takeover
|
||||
// waiting to be noticed. `applogin` answers a POST carrying an email and NO
|
||||
// password with 409 and the userid, for any account that has not set one —
|
||||
// which is how the console's own setup step learned it. So the whole recipe
|
||||
// was: know a merchant's primary email, which is usually printed on their
|
||||
// shopfront, POST it here, receive their userid, then set their password
|
||||
// and own the business's admin account. No guessing at any step.
|
||||
//
|
||||
// The invitation closes it. It is signed with the deployment's key, names
|
||||
// the account in a payload the server produced, and expires. Knowing an
|
||||
// email is no longer enough, and neither is knowing a userid.
|
||||
claims, err := utils.ParseInviteToken(req.Token, time.Now())
|
||||
if err != nil {
|
||||
return c.Status(http.StatusConflict).JSON(fiber.Map{
|
||||
"status": false, "code": http.StatusConflict, "message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := ctl.userService.SetInitialPassword(claims.Userid, req.Password); err != nil {
|
||||
// 409, not 401. Nothing about this is an authentication failure — the
|
||||
// caller is not supposed to have a session — and answering 401 would
|
||||
// send the console into its sign-out-and-reload path on the one screen
|
||||
|
||||
Reference in New Issue
Block a user