auto mail generation

This commit is contained in:
2026-09-29 16:53:21 +05:30
parent b18080d429
commit b46902f51b
27 changed files with 2765 additions and 80 deletions

View File

@@ -0,0 +1,337 @@
package services
import (
"errors"
"strings"
"testing"
"nearle/models"
"nearle/repositories"
)
/*
Every account that is created with no password gets invited.
── Why this file exists ─────────────────────────────────────────────────────
There are three ways a back-office login comes into being on this platform, and
all three write `password = ''`:
- `CreateTenantUser` — the merchant, at onboarding
- `CreateUser` / `CreateStaff` — a person added to the directory afterwards
- `CreateTenantLocation` — the login a branch spawns when no operator is named
Only the first was ever invited. That was survivable while the sign-in screen
carried a "set your password" form, because the other two could use it. That form
is gone — it was an account takeover, since the probe behind it answered any
email with the userid needed to claim the account — so an uninvited account is now
one nobody can ever sign in to. It is listed, it appears in every branch picker,
and the first person to find out is whoever is standing in the shop.
So these tests are about coverage of the three paths, not about the mail. What
the message says and when sending fails is `inviteService_test.go`.
*/
// countingInviter records who was invited. `countingInvites` in
// resendInvite_test.go counts calls and nothing else; this one keeps the
// arguments, because the whole question here is who got the mail.
type countingInviter struct {
calls int
userid int
tenantid int
email string
business string
sent bool
reason string
failEvery bool
}
func (c *countingInviter) Invite(userid, tenantid int, email, businessName string) (bool, string) {
c.calls++
c.userid, c.tenantid, c.email, c.business = userid, tenantid, email, businessName
if c.failEvery {
return false, c.reason
}
return c.sent, c.reason
}
/* ── A person added to the directory ─────────────────────────────────────── */
type stubUserRepo struct {
repositories.UserRepository
newid int
err error
}
func (r *stubUserRepo) CreateUser(models.User) (int, error) {
if r.err != nil {
return 0, r.err
}
return r.newid, nil
}
func (r *stubUserRepo) GetUserById(uid int) (models.UserInfo, error) {
return models.UserInfo{Userid: uid}, nil
}
func TestANewStaffAccountIsInvited(t *testing.T) {
invites := &countingInviter{sent: true}
service := NewUserService(&stubUserRepo{newid: 7781}, invites)
_, outcome, err := service.CreateUser(models.User{
Email: "meena@rmart.example", Tenantid: 1147,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if !outcome.Sent {
t.Fatalf("hired and not invited: %+v", outcome)
}
if invites.userid != 7781 {
t.Errorf("invited user %d, want the one that was just created (7781)", invites.userid)
}
if invites.email != "meena@rmart.example" {
t.Errorf("invitation addressed to %q", invites.email)
}
if invites.tenantid != 1147 {
// The token carries the tenant, and the mail names the business. A zero
// here is an invitation from nobody, to an account belonging to nobody.
t.Errorf("invited against tenant %d, want 1147", invites.tenantid)
}
}
func TestAStaffAccountWithOnlyAnAuthnameIsStillInvited(t *testing.T) {
// `PrepareNewAccount` copies email → authname, not the other way round, so a
// caller that filled in only the sign-in name leaves `Email` empty. That is
// the same mailbox, and refusing to write to it would strand the person over
// which of two identical fields was filled in.
invites := &countingInviter{sent: true}
service := NewUserService(&stubUserRepo{newid: 7782}, invites)
if _, outcome, err := service.CreateUser(models.User{
Authname: "arun@rmart.example", Tenantid: 1147,
}); err != nil || !outcome.Sent {
t.Fatalf("not invited: outcome=%+v err=%v", outcome, err)
}
if invites.email != "arun@rmart.example" {
t.Errorf("invitation addressed to %q, want the authname", invites.email)
}
}
func TestHiringSucceedsWhenTheInvitationDoesNot(t *testing.T) {
// The person is hired either way. A mail relay that refuses the address must
// not undo a hire — the operator resends, or corrects the address.
invites := &countingInviter{failEvery: true, reason: "mailbox full"}
service := NewUserService(&stubUserRepo{newid: 7783}, invites)
info, outcome, err := service.CreateUser(models.User{Email: "raj@rmart.example"})
if err != nil {
t.Fatalf("a failed invitation failed the hire: %v", err)
}
if info.Userid != 7783 {
t.Fatalf("the account was not created: %+v", info)
}
if outcome.Sent || outcome.Reason != "mailbox full" {
t.Fatalf("the reason was lost: %+v", outcome)
}
}
func TestAFailedCreateIsNotInvited(t *testing.T) {
invites := &countingInviter{sent: true}
service := NewUserService(&stubUserRepo{err: errors.New("duplicate authname")}, invites)
if _, _, err := service.CreateUser(models.User{Email: "raj@rmart.example"}); err == nil {
t.Fatal("a failed create was reported as success")
}
if invites.calls != 0 {
t.Fatal("invited an account that was never created")
}
}
func TestStaffCreatedThroughTheTenantPathIsAlsoInvited(t *testing.T) {
// Two endpoints make back-office accounts — `users/create` and
// `tenants/createstaff` — and the console has used both. An invitation on one
// only would be a gap nobody could see from the screen they were using.
invites := &countingInviter{sent: true}
service := NewTenantService(&recordingTenantRepo{}, invites)
outcome, err := service.CreateStaff(models.User{
Email: "kavi@rmart.example", Tenantid: 1147,
})
if err != nil {
t.Fatalf("CreateStaff: %v", err)
}
if !outcome.Sent || invites.userid != 5150 {
t.Fatalf("not invited, or the wrong account: outcome=%+v userid=%d", outcome, invites.userid)
}
}
/* ── The login a branch spawns ───────────────────────────────────────────── */
type branchRepo struct {
repositories.TenantRepository
spawned int
err error
}
func (r *branchRepo) CreateTenantLocation(data models.Tenantlocations) (models.Tenantlocations, int, error) {
if r.err != nil {
return models.Tenantlocations{}, 0, r.err
}
data.Locationid = 4420
return data, r.spawned, nil
}
func TestABranchesOwnLoginIsInvited(t *testing.T) {
invites := &countingInviter{sent: true}
service := NewTenantService(&branchRepo{spawned: 9001}, invites)
resp := service.CreateTenantLocation(models.Tenantlocations{
Locationname: "R Mart Peelamedu", Email: "peelamedu@rmart.example",
Tenantid: 1147, Address: "100 Feet Road", City: "Coimbatore",
})
if resp["status"] != true {
t.Fatalf("branch not created: %+v", resp)
}
if resp["invited"] != true {
t.Fatalf("the branch's own login was not invited: %+v", resp)
}
if invites.userid != 9001 {
t.Errorf("invited user %d, want the spawned login (9001)", invites.userid)
}
if invites.email != "peelamedu@rmart.example" {
t.Errorf("invitation addressed to %q, want the branch's email", invites.email)
}
}
func TestABranchHandedToAnExistingPersonSendsNothing(t *testing.T) {
// `spawned: 0` is the repository saying it created no account, because an
// `operatorid` was named. That person had a login before this branch existed,
// and re-inviting them would be a password reset in a branch's clothing —
// the one thing this whole mechanism is built to not become.
invites := &countingInviter{sent: true}
service := NewTenantService(&branchRepo{spawned: 0}, invites)
resp := service.CreateTenantLocation(models.Tenantlocations{
Locationname: "R Mart Gandhipuram", Operatorid: 7781, Tenantid: 1147,
})
if resp["status"] != true {
t.Fatalf("branch not created: %+v", resp)
}
if invites.calls != 0 {
t.Fatal("re-invited an existing person because a branch was commissioned")
}
if resp["invited"] != false {
t.Errorf("invited should be false when there was nobody to invite: %+v", resp)
}
if reason, _ := resp["invitereason"].(string); reason != "" {
// Nothing went wrong, so nothing is explained. A reason here reads as a
// failure on a screen that is reporting a success.
t.Errorf("apologised for an invitation that was never owed: %q", reason)
}
}
func TestAFailedBranchIsNotInvited(t *testing.T) {
invites := &countingInviter{sent: true}
service := NewTenantService(&branchRepo{err: errors.New("no such tenant")}, invites)
resp := service.CreateTenantLocation(models.Tenantlocations{
Locationname: "R Mart Nowhere", Email: "nowhere@rmart.example",
})
if resp["status"] != false {
t.Fatalf("a failed create was reported as success: %+v", resp)
}
if invites.calls != 0 {
t.Fatal("invited a login for a branch that does not exist")
}
}
/* ── Resending to one named person ───────────────────────────────────────── */
type userTargetRepo struct {
repositories.TenantRepository
target repositories.InviteTarget
err error
asked int
}
func (r *userTargetRepo) InviteTargetForUser(userID int) (repositories.InviteTarget, error) {
r.asked = userID
if r.err != nil {
return repositories.InviteTarget{}, r.err
}
return r.target, nil
}
func TestResendReachesAStaffMemberByUserid(t *testing.T) {
// The owner is reachable by tenantid because there is one of them. Everybody
// else has to be named, and before this there was no way to reach them at
// all — the only repair was editing the database.
repo := &userTargetRepo{target: repositories.InviteTarget{
Userid: 7781, Email: "meena@rmart.example", Tenantname: "R Mart",
}}
invites := &countingInviter{sent: true}
outcome, err := NewTenantService(repo, invites).ResendInviteToUser(7781)
if err != nil {
t.Fatalf("ResendInviteToUser: %v", err)
}
if !outcome.Sent || repo.asked != 7781 || invites.userid != 7781 {
t.Fatalf("wrong person: outcome=%+v asked=%d invited=%d", outcome, repo.asked, invites.userid)
}
}
func TestResendToAUserRefusesOneWhoAlreadyHasAPassword(t *testing.T) {
// Same line as the tenant resend draws, and it has to be drawn here too:
// this endpoint takes any userid, so without the check it would re-issue a
// working password link for every account on the platform.
repo := &userTargetRepo{target: repositories.InviteTarget{
Userid: 7781, Email: "meena@rmart.example", Tenantname: "R Mart", IsSetUp: true,
}}
invites := &countingInviter{sent: true}
_, err := NewTenantService(repo, invites).ResendInviteToUser(7781)
if err == nil {
t.Fatal("re-invited an account that already has a password")
}
if invites.calls != 0 {
t.Fatal("a link was minted for an account that is already set up")
}
if !strings.Contains(err.Error(), "sign-in") {
t.Errorf("the refusal does not say what to do instead: %v", err)
}
}
func TestResendToAUserWithNoBusinessStillReadsSensibly(t *testing.T) {
// A back-office account with no tenant is a Nearle staff row, and one exists.
// The refusal interpolates the business name, so an empty one would read
// " has already set a password".
repo := &userTargetRepo{target: repositories.InviteTarget{
Userid: 12, Email: "ops@nearle.in", Tenantname: "", IsSetUp: true,
}}
_, err := NewTenantService(repo, &countingInviter{}).ResendInviteToUser(12)
if err == nil {
t.Fatal("re-invited an account that already has a password")
}
if strings.HasPrefix(err.Error(), " ") || strings.Contains(err.Error(), " ") {
t.Errorf("the refusal has a hole where the business name should be: %q", err)
}
}
func TestResendToAUserPassesThroughALookupFailure(t *testing.T) {
repo := &userTargetRepo{err: errors.New("user 99 is not a back-office account on this platform")}
invites := &countingInviter{}
_, err := NewTenantService(repo, invites).ResendInviteToUser(99)
if err == nil || !strings.Contains(err.Error(), "back-office") {
t.Fatalf("the lookup's reason was lost: %v", err)
}
if invites.calls != 0 {
t.Fatal("a link was minted for an account that could not be found")
}
}

151
services/inviteService.go Normal file
View File

@@ -0,0 +1,151 @@
package services
import (
"fmt"
"log"
"strings"
"time"
"nearle/config"
"nearle/utils"
)
// The invitation a newly onboarded merchant receives.
//
// ── Why onboarding does not fail when this does ─────────────────────────────
//
// `Invite` never returns an error to the onboarding path. A tenant that exists
// and has not been emailed is recoverable — somebody presses resend — while a
// tenant rolled back because a mail relay was slow is a business that was
// onboarded, told it was onboarded, and is not in the system. The first is a
// task; the second is a phone call nobody can explain.
//
// So a failure is logged loudly and reported as `false`, and the caller decides
// what to tell the operator. The platform console shows "invitation not sent"
// beside the tenant, which is the state somebody can act on.
type InviteService interface {
// Invite emails a first-password link. Reports whether it was sent, and
// why not when it was not — a sentence for the operator, not an error.
//
// `businessName` may be empty: the name is then looked up from `tenantid`,
// because most callers hold an account and a tenantid and nothing else about
// the business. A caller that already has the name — onboarding, which was
// handed it in the form — passes it and saves the query.
Invite(userid, tenantid int, email, businessName string) (bool, string)
}
// TenantNamer reads a business's name for the invitation's first line.
//
// A one-method interface rather than the whole tenant repository, because that
// is all this needs and because it keeps `inviteService` testable without a
// database. `repositories.TenantRepository` satisfies it.
type TenantNamer interface {
TenantNameByID(tenantID int) (string, error)
}
type inviteService struct {
mailer utils.Mailer
cfg config.MailConfig
// May be nil. The invitation then says "your business", which is worse copy
// and a working link — never a reason not to send.
names TenantNamer
}
func NewInviteService(mailer utils.Mailer, cfg config.MailConfig, names TenantNamer) InviteService {
return &inviteService{mailer: mailer, cfg: cfg, names: names}
}
func (s *inviteService) Invite(userid, tenantid int, email, businessName string) (bool, string) {
address := strings.TrimSpace(email)
if address == "" {
return false, "no email address on the account"
}
if s.mailer == nil {
// Not a fault. A deployment with no mail configured still onboards; the
// reason names the variable so it is fixable rather than mysterious.
return false, s.cfg.Why()
}
token, _, err := utils.MintInviteToken(
utils.InviteClaims{Userid: userid, Tenantid: tenantid}, time.Now())
if err != nil {
// Only happens with no signing secret, which is already fatal at boot
// in production — but an invitation with no token would be a link that
// cannot work, and sending it would be worse than not sending.
log.Printf("invite: could not sign an invitation for user %d: %v", userid, err)
return false, "this server cannot sign an invitation"
}
subject, body := inviteMessage(s.businessName(tenantid, businessName), s.cfg.InviteLink(token))
if err := s.mailer.Send(address, subject, body); err != nil {
log.Printf("invite: could not email user %d at %s: %v", userid, address, err)
return false, err.Error()
}
log.Printf("invite: sent to user %d for tenant %d", userid, tenantid)
return true, ""
}
// businessName is the name for the mail's first line.
//
// Looked up only when the caller did not have one. A failure is logged and
// swallowed: the alternative is refusing to send somebody their only way into
// their account because a name could not be read, and "your business has been
// set up on Nearle" is a perfectly usable sentence.
func (s *inviteService) businessName(tenantid int, given string) string {
if name := strings.TrimSpace(given); name != "" {
return name
}
if s.names == nil || tenantid <= 0 {
return ""
}
name, err := s.names.TenantNameByID(tenantid)
if err != nil {
log.Printf("invite: could not read tenant %d's name: %v", tenantid, err)
return ""
}
return strings.TrimSpace(name)
}
// inviteMessage is what the merchant reads.
//
// ── Why it says so little ───────────────────────────────────────────────────
//
// This is the first thing a new merchant receives from us and the only way into
// their account, so it has one job: make the link obvious and make it credible.
// Every extra paragraph is somewhere for the link to hide, and a mail full of
// features reads like marketing — which is the thing people delete.
//
// It states who it is for and what it does, gives the link on its own line, and
// says how long it lasts. The expiry is there because an invitation found three
// weeks later needs to explain itself rather than look broken.
//
// Plain text, not HTML. A password link that arrives as an image-heavy template
// is the shape of a phishing mail, and plain text renders identically
// everywhere.
func inviteMessage(businessName, link string) (subject, body string) {
name := strings.TrimSpace(businessName)
if name == "" {
name = "your business"
}
subject = "Set your Nearle password"
body = fmt.Sprintf(`%s has been set up on Nearle.
To finish, choose a password for your account:
%s
This link is for you alone and works once. It expires in 7 days — if it has,
ask whoever set you up to send another.
If you were not expecting this, you can ignore it. Nothing happens until
somebody uses the link.
— Nearle
`, name, link)
return subject, body
}

View File

@@ -0,0 +1,241 @@
package services
import (
"errors"
"strings"
"testing"
"nearle/config"
)
/*
The invitation a newly onboarded merchant receives.
It is the only way into their account, so the tests are about two things: that a
failure to send never costs them the tenant, and that the message itself is one
a person will act on rather than delete.
*/
type recordingMailer struct {
to, subject, body string
refuse error
sent int
}
func (m *recordingMailer) Send(to, subject, body string) error {
if m.refuse != nil {
return m.refuse
}
m.to, m.subject, m.body = to, subject, body
m.sent++
return nil
}
func workingMail() config.MailConfig {
return config.MailConfig{
Host: "smtp.example.com", Port: 587,
FromAddress: "noreply@nearledaily.com", FromName: "Nearle",
ConsoleURL: "https://app.nearledaily.com",
}
}
func TestAnInvitationCarriesALinkAndNothingElseIdentifying(t *testing.T) {
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
mailer := &recordingMailer{}
sent, reason := NewInviteService(mailer, workingMail(), nil).
Invite(904, 1147, "owner@rmart.example", "R Mart")
if !sent {
t.Fatalf("not sent: %s", reason)
}
if mailer.to != "owner@rmart.example" {
t.Fatalf("addressed to %q", mailer.to)
}
if !strings.Contains(mailer.body, "https://app.nearledaily.com/set-password?t=i1.") {
t.Fatalf("no invitation link in the body:\n%s", mailer.body)
}
// The token is the whole credential, so it is the only thing in the URL.
// An email address or a userid in a query string ends up in server logs,
// browser history and whatever proxy sits between — which would put both
// halves of an account somewhere neither belongs.
link := mailer.body[strings.Index(mailer.body, "https://"):]
link = strings.Fields(link)[0]
if strings.Contains(link, "@") || strings.Contains(link, "904") {
t.Fatalf("the link identifies the account beyond the token: %s", link)
}
}
func TestTheInvitationNamesTheBusinessAndTheExpiry(t *testing.T) {
// Named, because this arrives unannounced at an address the merchant gave
// during a sales conversation weeks earlier. "Your business has been set
// up" reads like a phishing template; their own name does not.
//
// The expiry is there so an invitation found three weeks later explains
// itself rather than looking broken.
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
mailer := &recordingMailer{}
NewInviteService(mailer, workingMail(), nil).Invite(904, 1147, "owner@rmart.example", "R Mart")
if !strings.Contains(mailer.body, "R Mart") {
t.Fatalf("the business is not named:\n%s", mailer.body)
}
if !strings.Contains(mailer.body, "7 days") {
t.Fatalf("the expiry is not stated:\n%s", mailer.body)
}
if strings.TrimSpace(mailer.subject) == "" {
t.Fatal("no subject")
}
}
func TestAnUnnamedBusinessStillReadsAsASentence(t *testing.T) {
// `tenantname` is not enforced anywhere upstream, and " has been set up on
// Nearle" is the kind of thing that ships.
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
mailer := &recordingMailer{}
NewInviteService(mailer, workingMail(), nil).Invite(904, 1147, "owner@rmart.example", " ")
if strings.Contains(mailer.body, " has been set up") {
t.Fatalf("the blank name left a gap:\n%s", mailer.body)
}
if !strings.Contains(mailer.body, "your business") {
t.Fatalf("no fallback for an unnamed business:\n%s", mailer.body)
}
}
func TestNoMailerMeansNotSentRatherThanAPanic(t *testing.T) {
// The ordinary state of a deployment that has not configured mail. It must
// report, not crash and not pretend.
sent, reason := NewInviteService(nil, config.MailConfig{}, nil).
Invite(904, 1147, "owner@rmart.example", "R Mart")
if sent {
t.Fatal("reported as sent with no mailer")
}
if !strings.Contains(reason, "MAIL_HOST") {
t.Fatalf("the reason does not name what is missing: %q", reason)
}
}
func TestARefusedSendIsReportedNotSwallowed(t *testing.T) {
// The operator has to learn that the merchant was not emailed, or the
// merchant waits for a link that never comes and nobody knows.
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
mailer := &recordingMailer{refuse: errors.New("mailbox full")}
sent, reason := NewInviteService(mailer, workingMail(), nil).
Invite(904, 1147, "owner@rmart.example", "R Mart")
if sent {
t.Fatal("a refused send reported as sent")
}
if !strings.Contains(reason, "mailbox full") {
t.Fatalf("the provider's reason was lost: %q", reason)
}
}
func TestAnAccountWithNoEmailIsNotInvited(t *testing.T) {
// `primaryemail` is not enforced at creation. Worth reporting rather than
// handing an empty address to the relay and reading its refusal instead.
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
mailer := &recordingMailer{}
sent, reason := NewInviteService(mailer, workingMail(), nil).Invite(904, 1147, " ", "R Mart")
if sent || mailer.sent != 0 {
t.Fatal("an invitation was sent to nobody")
}
if !strings.Contains(reason, "no email") {
t.Fatalf("unhelpful reason: %q", reason)
}
}
func TestNoSigningSecretMeansNoInvitationRatherThanADeadLink(t *testing.T) {
// Sending a link that cannot work is worse than not sending: the merchant
// tries it, it fails, and the failure looks like the product.
t.Setenv("POS_TOKEN_SECRET", "")
t.Setenv("JWT_SECRET_KEY", "")
mailer := &recordingMailer{}
sent, _ := NewInviteService(mailer, workingMail(), nil).
Invite(904, 1147, "owner@rmart.example", "R Mart")
if sent || mailer.sent != 0 {
t.Fatal("an invitation went out with no signing secret")
}
}
/* ── Whose name is on the mail ───────────────────────────────────────────── */
type stubNamer struct {
name string
err error
asked int
}
func (s *stubNamer) TenantNameByID(tenantID int) (string, error) {
s.asked = tenantID
return s.name, s.err
}
func TestTheBusinessNameIsLookedUpWhenTheCallerHasNone(t *testing.T) {
// A staff row arrives with a tenantid and nothing else about the business, so
// the caller cannot name it. Without the lookup every invitation but the
// merchant's own would open "your business has been set up on Nearle".
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
mailer := &recordingMailer{}
namer := &stubNamer{name: "R Mart"}
if sent, reason := NewInviteService(mailer, workingMail(), namer).
Invite(7781, 1147, "meena@rmart.example", ""); !sent {
t.Fatalf("not sent: %s", reason)
}
if namer.asked != 1147 {
t.Errorf("looked up tenant %d, want 1147", namer.asked)
}
if !strings.Contains(mailer.body, "R Mart") {
t.Errorf("the mail does not name the business:\n%s", mailer.body)
}
}
func TestACallerThatKnowsTheNameIsNotMadeToLookItUp(t *testing.T) {
// Onboarding was handed the name in the form. A query to learn something the
// caller already holds is a round trip inside a request somebody is waiting
// on, for a guaranteed identical answer.
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
namer := &stubNamer{name: "Should Not Be Read"}
NewInviteService(&recordingMailer{}, workingMail(), namer).
Invite(904, 1147, "owner@rmart.example", "R Mart")
if namer.asked != 0 {
t.Error("looked the name up although the caller passed one")
}
}
func TestAnUnreadableBusinessNameStillSendsTheInvitation(t *testing.T) {
// The name is one word in the first line. The link is the person's only way
// into their account, and refusing to send it over a failed lookup would
// trade a worse sentence for somebody locked out.
t.Setenv("POS_TOKEN_SECRET", "a-signing-secret-of-ample-length")
mailer := &recordingMailer{}
namer := &stubNamer{err: errors.New("connection reset")}
sent, reason := NewInviteService(mailer, workingMail(), namer).
Invite(7781, 1147, "meena@rmart.example", "")
if !sent {
t.Fatalf("a failed name lookup stopped the invitation: %s", reason)
}
if !strings.Contains(mailer.body, "your business") {
t.Errorf("no fallback for the missing name:\n%s", mailer.body)
}
if !strings.Contains(mailer.body, "set-password?t=") {
t.Errorf("the link is missing:\n%s", mailer.body)
}
}

View File

@@ -93,7 +93,7 @@ func (r *recordingUserRepo) GetUserById(uid int) (models.UserInfo, error) {
func TestCreateUserActuallyPreparesTheAccount(t *testing.T) {
repo := &recordingUserRepo{}
if _, err := NewUserService(repo).CreateUser(models.User{
if _, _, err := NewUserService(repo, nil).CreateUser(models.User{
Email: "thiruomart@gmail.com",
}); err != nil {
t.Fatalf("CreateUser: %v", err)
@@ -114,16 +114,16 @@ type recordingTenantRepo struct {
created models.User
}
func (r *recordingTenantRepo) CreateStaff(user models.User) error {
func (r *recordingTenantRepo) CreateStaff(user models.User) (int, error) {
r.created = user
return nil
return 5150, nil
}
// The other creation path. Both make back-office accounts, so both have to
// prepare them — and only one of them did.
func TestCreateStaffActuallyPreparesTheAccount(t *testing.T) {
repo := &recordingTenantRepo{}
if err := NewTenantService(repo).CreateStaff(models.User{Email: "suriya@example.com"}); err != nil {
if _, err := NewTenantService(repo, nil).CreateStaff(models.User{Email: "suriya@example.com"}); err != nil {
t.Fatalf("CreateStaff: %v", err)
}
if repo.created.Authname != "suriya@example.com" || repo.created.Configid != ConsoleConfigID {

View File

@@ -0,0 +1,168 @@
package services
import (
"errors"
"strings"
"testing"
"nearle/config"
"nearle/models"
"nearle/repositories"
)
/*
Re-issuing a merchant's first-password link.
Invitations get lost — spam folders, typo'd addresses, a seven-day expiry that
runs out over a holiday. Without a resend the only recovery is a database edit,
so this exists.
It is also the endpoint most at risk of quietly becoming something else. An
endpoint that re-issues a working password link for any account IS a password
reset, whatever it is called, and nothing on this backend verifies identity well
enough to support one. So most of what follows is about what it refuses.
*/
type inviteRepo struct {
repositories.TenantRepository
target repositories.InviteTarget
err error
}
func (r *inviteRepo) PrimaryAdminForTenant(int) (repositories.InviteTarget, error) {
if r.err != nil {
return repositories.InviteTarget{}, r.err
}
return r.target, nil
}
type countingInvites struct {
calls int
sent bool
reason string
}
func (c *countingInvites) Invite(_, _ int, _, _ string) (bool, string) {
c.calls++
return c.sent, c.reason
}
func waiting() repositories.InviteTarget {
return repositories.InviteTarget{
Userid: 904, Email: "owner@rmart.example", Tenantname: "R Mart", IsSetUp: false,
}
}
func TestResendEmailsAMerchantWhoNeverGotOne(t *testing.T) {
invites := &countingInvites{sent: true}
service := NewTenantService(&inviteRepo{target: waiting()}, invites)
outcome, err := service.ResendInvite(1147)
if err != nil {
t.Fatalf("resend: %v", err)
}
if !outcome.Sent || invites.calls != 1 {
t.Fatalf("not sent: %+v, calls=%d", outcome, invites.calls)
}
}
func TestResendRefusesAMerchantWhoAlreadyHasAPassword(t *testing.T) {
// The line between a resend and a password reset.
//
// `SetInitialPassword` would refuse such a link anyway, so the merchant
// could come to no harm — but the operator would be told mail was sent, the
// merchant would follow a link that does nothing, and neither would know
// why. Refusing here names the real situation.
target := waiting()
target.IsSetUp = true
invites := &countingInvites{sent: true}
service := NewTenantService(&inviteRepo{target: target}, invites)
_, err := service.ResendInvite(1147)
if err == nil {
t.Fatal("re-invited an account that already has a password")
}
if invites.calls != 0 {
t.Fatal("a link was minted for an account that is already set up")
}
// Says what to do instead, because the merchant's actual problem is signing
// in rather than setting up.
if !strings.Contains(err.Error(), "sign-in") {
t.Fatalf("the refusal does not say what to do instead: %v", err)
}
}
func TestResendNamesTheBusinessItRefused(t *testing.T) {
// An operator working through a list needs to know which one, not that
// "an account" was already set up.
target := waiting()
target.IsSetUp = true
_, err := NewTenantService(&inviteRepo{target: target}, &countingInvites{}).ResendInvite(1147)
if err == nil || !strings.Contains(err.Error(), "R Mart") {
t.Fatalf("the refusal does not name the business: %v", err)
}
}
func TestResendPassesThroughALookupFailure(t *testing.T) {
// No such tenant, or one whose primary email matches no login — which
// happens when the address is changed on the tenant without the account
// being changed with it. The fix is to correct one of the two, so the
// message has to survive rather than become "could not resend".
repo := &inviteRepo{err: errors.New("tenant 1147 has no account matching its primary email address")}
invites := &countingInvites{}
_, err := NewTenantService(repo, invites).ResendInvite(1147)
if err == nil || !strings.Contains(err.Error(), "primary email") {
t.Fatalf("the lookup's reason was lost: %v", err)
}
if invites.calls != 0 {
t.Fatal("a link was minted for an account that could not be found")
}
}
func TestResendWithNoMailConfiguredSaysSoRatherThanFailing(t *testing.T) {
// Not an error: the tenant is fine and the server simply cannot send. The
// controller turns this into a refusal for the operator, with the variable
// named.
service := NewTenantService(&inviteRepo{target: waiting()}, nil)
outcome, err := service.ResendInvite(1147)
if err != nil {
t.Fatalf("unconfigured mail reported as an error: %v", err)
}
if outcome.Sent || !strings.Contains(outcome.Reason, "not configured") {
t.Fatalf("unhelpful outcome: %+v", outcome)
}
}
func TestResendReportsWhyTheMailWasRefused(t *testing.T) {
invites := &countingInvites{sent: false, reason: "mailbox full"}
service := NewTenantService(&inviteRepo{target: waiting()}, invites)
outcome, err := service.ResendInvite(1147)
if err != nil {
t.Fatalf("resend: %v", err)
}
if outcome.Sent || outcome.Reason != "mailbox full" {
t.Fatalf("the provider's reason was lost: %+v", outcome)
}
}
// Onboarding and resend share the invite path, so a nil mailer must be safe on
// both. This is the create side.
func TestOnboardingWithNoMailStillCreatesTheTenant(t *testing.T) {
_ = models.Tenants{}
_ = config.MailConfig{}
service := NewTenantService(&inviteRepo{target: waiting()}, nil)
outcome := service.(*tenantService).inviteFor(models.UserInfo{Userid: 904}, models.Tenants{})
if outcome.Sent {
t.Fatal("reported as sent with no invite service")
}
if outcome.Reason == "" {
t.Fatal("not sent, and no reason for the operator")
}
}

View File

@@ -2,6 +2,7 @@ package services
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -25,22 +26,43 @@ type TenantService interface {
UpdateTenantProfile(tenantID int, fields map[string]any) error
UpdateOwnProfile(userID, tenantID int, fields map[string]any) error
GetStaffs(tid int) ([]models.StaffInfo, error)
CreateStaff(user models.User) error
// Adds a back-office person and emails their first-password invitation.
//
// Same shape as `CreateTenantUser`, and for the same reason: the account is
// created with no password, and whether the mail left is a separate fact the
// console has to show. A failure to send is not a failure to hire.
CreateStaff(user models.User) (InviteOutcome, error)
AssignStaffToBranch(tenantID, userID, locationID int) error
UpdateStaff(user models.User) error
CreateTenantLocation(data models.Tenantlocations) map[string]interface{}
UpdateTenantLocation(data models.Tenantlocations) map[string]interface{}
CreateTenantUser(data models.Tenants) (models.UserInfo, error)
// Onboards a merchant and emails their first-password invitation.
//
// The outcome is returned rather than stashed on the service: it belongs to
// one call, and a field would race between two operators onboarding at the
// same moment.
CreateTenantUser(data models.Tenants) (models.UserInfo, InviteOutcome, error)
// ResendInvite re-issues a first-password link for a tenant that never got
// one, or whose invitation expired.
ResendInvite(tenantID int) (InviteOutcome, error)
// ResendInviteToUser does the same for one named account — a staff member or
// a branch's own login, neither of which is reachable by tenantid because a
// business has many of them.
ResendInviteToUser(userID int) (InviteOutcome, error)
GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error)
GetTenantByKeyword(keyword string) ([]models.TenantSearch, error)
}
type tenantService struct {
repo repositories.TenantRepository
// May be nil. A deployment with no mail configured still onboards tenants
// — the merchant is told by whoever set them up — and the outcome says so
// rather than the creation failing.
invites InviteService
}
func NewTenantService(repo repositories.TenantRepository) TenantService {
return &tenantService{repo: repo}
func NewTenantService(repo repositories.TenantRepository, invites InviteService) TenantService {
return &tenantService{repo: repo, invites: invites}
}
func (s *tenantService) SearchTenant(status, keyword string) ([]models.Tenantinfo, error) {
@@ -105,11 +127,53 @@ func (s *tenantService) GetStaffs(tid int) ([]models.StaffInfo, error) {
return s.repo.GetStaffs(tid)
}
func (s *tenantService) CreateStaff(user models.User) error {
func (s *tenantService) CreateStaff(user models.User) (InviteOutcome, error) {
// Same two fields, same reason: without an authname and a console configid
// the account is created, listed, and refused at the login screen. This path
// and users/create both make back-office accounts, so both need it.
return s.repo.CreateStaff(PrepareNewAccount(user))
ready := PrepareNewAccount(user)
userid, err := s.repo.CreateStaff(ready)
if err != nil {
return InviteOutcome{}, err
}
// The invitation, for the same reason onboarding sends one: this account is
// created with no password, and the sign-in screen no longer offers to set
// one. Without the mail the person is added to the directory, appears in
// every branch picker, and cannot sign in — and nothing anywhere would say
// so. That was true for a while, and this is the fix.
//
// After the write and outside it, like the tenant's. A person who exists and
// was not emailed is a resend; a person rolled back by a slow mail relay is
// somebody the manager was told they had hired.
return s.inviteAccount(userid, ready.Tenantid, ready.Email, ready.Authname), nil
}
// inviteAccount emails one newly created back-office account.
//
// The business name is left to the invite service, which looks it up from the
// tenantid: a staff row arrives with an id and nothing else about the business,
// and every caller doing that lookup itself would be the same query written four
// times.
func (s *tenantService) inviteAccount(userid, tenantid int, email, authname string) InviteOutcome {
if s.invites == nil {
return InviteOutcome{Reason: "invitations are not configured on this server"}
}
if userid <= 0 {
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
}
address := strings.TrimSpace(email)
if address == "" {
// The authname IS the email on every back-office account — `users/create`
// and `createstaff` both copy one to the other — so this is a fallback
// for a caller that filled in only one of the two, not a second address.
address = strings.TrimSpace(authname)
}
sent, reason := s.invites.Invite(userid, tenantid, address, "")
return InviteOutcome{Sent: sent, Reason: reason}
}
func (s *tenantService) UpdateStaff(user models.User) error {
@@ -125,7 +189,7 @@ func (s *tenantService) CreateTenantLocation(data models.Tenantlocations) map[st
data.Address, data.Suburb, data.City, data.State, data.Postcode)
}
created, err := s.repo.CreateTenantLocation(data)
created, spawnedUserid, err := s.repo.CreateTenantLocation(data)
if err != nil {
return map[string]interface{}{
"code": http.StatusConflict,
@@ -134,6 +198,17 @@ func (s *tenantService) CreateTenantLocation(data models.Tenantlocations) map[st
}
}
// A branch that spawned its own login needs that login invited — it is
// created with no password, and the invitation is the only way to set one.
// `spawnedUserid` is 0 when an existing person was named instead, and there
// is deliberately nothing to send then: they had an account before this
// branch existed, and re-inviting somebody who may already have a password
// would be a password reset wearing a branch's clothes.
invite := InviteOutcome{}
if spawnedUserid > 0 {
invite = s.inviteAccount(spawnedUserid, data.Tenantid, data.Email, data.Email)
}
// "details" carries back the DB-assigned locationid so the frontend can
// build the store's QR code (tenantid+locationid) immediately after
// onboarding, instead of having to look the new location up separately.
@@ -142,6 +217,21 @@ func (s *tenantService) CreateTenantLocation(data models.Tenantlocations) map[st
"message": "Tenant Location Successfully Created",
"status": true,
"details": created,
// Beside "details" for the same reason it is on the tenant create: the
// branch exists either way, and whether its operator was emailed is a
// separate fact the console has to be able to show.
"invited": invite.Sent,
// Omitted when it sent, and when there was nobody to send to — a branch
// handed to an existing person has no invitation to report, and an
// apology there would read as a failure.
"invitereason": invite.Reason,
// Who to resend to, when it did not go. 0 when no login was spawned.
//
// The console cannot work this out: `details` is the tenantlocations row,
// and the account lives in `app_users`. Without this the only route to a
// resend is finding the right row in the people list by eye, on a screen
// that has just told somebody the mail failed.
"inviteuserid": spawnedUserid,
}
}
@@ -162,11 +252,11 @@ func (s *tenantService) UpdateTenantLocation(data models.Tenantlocations) map[st
}
}
func (s *tenantService) CreateTenantUser(data models.Tenants) (models.UserInfo, error) {
func (s *tenantService) CreateTenantUser(data models.Tenants) (models.UserInfo, InviteOutcome, error) {
// ✅ Check if tenant already exists
exists := s.repo.CheckTenantByNo(data.Primarycontact)
if exists != 0 {
return models.UserInfo{}, errors.New("Tenant Already Exists")
return models.UserInfo{}, InviteOutcome{}, errors.New("Tenant Already Exists")
}
// Coordinates from the address, for the tenant and its primary outlet.
@@ -184,12 +274,61 @@ func (s *tenantService) CreateTenantUser(data models.Tenants) (models.UserInfo,
// ✅ Create Tenant User
status, err := s.repo.CreateTenantUser(data)
if err != nil || !status {
return models.UserInfo{}, err
return models.UserInfo{}, InviteOutcome{}, err
}
// ✅ Get user details by contact number
result := s.repo.GetUserByNo(data.Primarycontact)
return result, nil
// The invitation, sent after everything above is committed and never inside
// it. `CreateTenantUser` in the repository runs a transaction; this does not
// join it.
//
// A tenant that exists and has not been emailed is recoverable — somebody
// presses resend. A tenant rolled back because a mail relay was slow is a
// business that was onboarded, told it was onboarded, and is not in the
// system. The first is a task; the second is a phone call nobody can
// explain.
//
// The account being invited is the one the repository just wrote: primary
// email as the authname, roleid 3, and no password. That empty password is
// what makes the invitation the only way in, and what `SetInitialPassword`
// re-checks before it writes.
return result, s.inviteFor(result, data), nil
}
// InviteOutcome is what the operator is told about the invitation.
//
// Its own type rather than a bool, because "not sent" is only useful with the
// reason attached: somebody who sees a tenant created and no mail sent needs to
// know whether to correct an address or set a variable.
//
// Returned rather than stashed on the service. The first version of this kept
// it in a field for the controller to read afterwards, which races — the
// service is one shared instance, and two operators onboarding at the same
// moment would each read the other's result. A value belonging to one call
// travels with that call.
type InviteOutcome struct {
Sent bool
Reason string
}
// inviteFor emails the new merchant, and says what happened.
//
// Never returns an error: the outcome is for the operator who onboarded them,
// not something for the caller to fail on.
func (s *tenantService) inviteFor(user models.UserInfo, data models.Tenants) InviteOutcome {
if s.invites == nil {
return InviteOutcome{Reason: "invitations are not configured on this server"}
}
if user.Userid <= 0 {
// The account was written but could not be read back, so there is
// nobody to address. Worth saying rather than silently not sending.
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
}
sent, reason := s.invites.Invite(user.Userid, user.Tenantid, data.Primaryemail, data.Tenantname)
return InviteOutcome{Sent: sent, Reason: reason}
}
func (s *tenantService) GetTenantByID(tid int, locationid int, userid int) (models.Tenantinfo, error) {
@@ -268,3 +407,63 @@ func (s *tenantService) UpdateOwnProfile(userID, tenantID int, fields map[string
func (s *tenantService) AssignPartner(tenantID, partnerID int) error {
return s.repo.AssignPartner(tenantID, partnerID)
}
// ResendInvite emails a fresh first-password link to a tenant's admin.
//
// ── Why it refuses an account that is already set up ────────────────────────
//
// `SetInitialPassword` would refuse such a link anyway, so the merchant could
// come to no harm — but the operator would be told the invitation was sent, the
// merchant would follow a link that does not work, and nobody would understand
// why. Refusing here names the real situation: they already have a password,
// and what they need is help signing in.
//
// It also keeps this from quietly becoming a password reset. Nothing on this
// backend verifies identity well enough to support one, and an endpoint that
// re-issues a working link for any account is that, whatever it is called.
func (s *tenantService) ResendInvite(tenantID int) (InviteOutcome, error) {
target, err := s.repo.PrimaryAdminForTenant(tenantID)
if err != nil {
return InviteOutcome{}, err
}
return s.resendTo(target, tenantID)
}
// ResendInviteToUser re-invites one named account.
//
// The owner is reachable by tenantid because there is exactly one of them. Staff
// added after onboarding, and the login every branch spawns, are not — a business
// has many, and all of them are created with no password. So an operator chasing
// a branch manager who never received their mail names the person.
//
// Same refusals as above, for the same reason: this must not become a password
// reset for anybody whose userid can be found.
func (s *tenantService) ResendInviteToUser(userID int) (InviteOutcome, error) {
target, err := s.repo.InviteTargetForUser(userID)
if err != nil {
return InviteOutcome{}, err
}
return s.resendTo(target, 0)
}
// resendTo is the half the two resends share.
//
// `tenantID` is passed in rather than read off the target because the token's
// claim should carry the tenant the CALLER asked about; a staff resend has no
// tenant in hand and 0 is honest about that.
func (s *tenantService) resendTo(target repositories.InviteTarget, tenantID int) (InviteOutcome, error) {
if target.IsSetUp {
who := strings.TrimSpace(target.Tenantname)
if who == "" {
who = "That account"
}
return InviteOutcome{}, fmt.Errorf(
"%s has already set a password — send them to the sign-in page instead", who)
}
if s.invites == nil {
return InviteOutcome{Reason: "invitations are not configured on this server"}, nil
}
sent, reason := s.invites.Invite(target.Userid, tenantID, target.Email, target.Tenantname)
return InviteOutcome{Sent: sent, Reason: reason}, nil
}

View File

@@ -50,7 +50,7 @@ func (r *loginRepo) GetTenantUserById(uid int) models.TenantUserInfo {
func TestADatabaseFailureIsNotAnInvalidEmail(t *testing.T) {
repo := &loginRepo{err: errors.New("dial tcp 10.0.0.5:5433: connection refused")}
svc := NewUserService(repo)
svc := NewUserService(repo, nil)
user := models.User{Authname: "owner@shop.example", Password: "pw", Configid: 1}
t.Run("app login", func(t *testing.T) {
@@ -81,7 +81,7 @@ func TestADatabaseFailureIsNotAnInvalidEmail(t *testing.T) {
// registered" from every other failure, so it must survive the refactor.
func TestNobodyMatchingIsStillInvalidEmail(t *testing.T) {
repo := &loginRepo{} // uid 0, no error: the query ran and found no row
svc := NewUserService(repo)
svc := NewUserService(repo, nil)
user := models.User{Authname: "nobody@shop.example", Password: "pw", Configid: 1}
_, resp, err := svc.AppLogin(user)
@@ -97,7 +97,7 @@ func TestNobodyMatchingIsStillInvalidEmail(t *testing.T) {
func TestLookupPrefersAuthnameAndFallsBackToContactNo(t *testing.T) {
repo := &loginRepo{uid: 7, password: "pw", status: "Active"}
svc := NewUserService(repo)
svc := NewUserService(repo, nil)
svc.AppLogin(models.User{Authname: "owner@shop.example", Contactno: "9999999999", Password: "pw", Configid: 1})
if repo.field != "authname" || repo.value != "owner@shop.example" || repo.configid != 1 {
@@ -112,7 +112,7 @@ func TestLookupPrefersAuthnameAndFallsBackToContactNo(t *testing.T) {
func TestNeitherIdentifierIsRefusedBeforeTheLookup(t *testing.T) {
repo := &loginRepo{err: errors.New("must not be called")}
svc := NewUserService(repo)
svc := NewUserService(repo, nil)
_, resp, err := svc.AppLogin(models.User{Password: "pw", Configid: 1})
if err == nil || resp["code"] != 400 {
@@ -125,7 +125,7 @@ func TestNeitherIdentifierIsRefusedBeforeTheLookup(t *testing.T) {
func TestAMatchedAccountStillSignsIn(t *testing.T) {
repo := &loginRepo{uid: 42, password: "secret", status: "Active", roleid: 2}
svc := NewUserService(repo)
svc := NewUserService(repo, nil)
info, resp, err := svc.AppLogin(models.User{Authname: "owner@shop.example", Password: "secret", Configid: 1})
if err != nil || resp["code"] != 200 || info.Userid != 42 {

View File

@@ -63,17 +63,25 @@ type UserService interface {
// the repository for what makes that safe.
SetInitialPassword(userid int, password string) error
AppLogin(user models.User) (models.TenantUserInfo, fiber.Map, error)
CreateUser(user models.User) (models.UserInfo, error)
// Creates a back-office account and emails its first-password invitation.
//
// The outcome travels beside the user rather than as an error: the person is
// hired either way, and whether the mail left is something the console shows
// so somebody can resend it.
CreateUser(user models.User) (models.UserInfo, InviteOutcome, error)
TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{})
DeleteUser(userid int) error
}
type userService struct {
repo repositories.UserRepository
// May be nil, like the tenant service's. A deployment with no mail still
// creates accounts; the outcome names the missing variable.
invites InviteService
}
func NewUserService(repo repositories.UserRepository) UserService {
return &userService{repo: repo}
func NewUserService(repo repositories.UserRepository, invites InviteService) UserService {
return &userService{repo: repo, invites: invites}
}
func (s *userService) GetAllUsers(roleID, tenantID, pageno, pagesize int, keyword string) ([]models.UserInfo, error) {
@@ -162,16 +170,35 @@ func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.M
return models.TenantUserInfo{}, resp, errors.New("inactive account")
}
// No password set
// No password set.
//
// ── The userid used to be in here, and that was the whole exploit ───────
//
// This branch is reached by a POST carrying an email and NO password, so
// anyone could ask it about any account. It answered with the userid, and
// `setpassword` then took a bare userid — so the recipe was: read a
// merchant's primary email off their shopfront, POST it here, receive their
// userid, set their password, own the business's admin account. No guessing
// at any step.
//
// `setpassword` now requires a signed invitation, so the userid alone is no
// longer a way in. It is still removed, because handing it out told an
// unauthenticated caller which businesses exist and which have never been
// set up — a list worth having if you are the one sending the phishing
// email that arrives before the real invitation does.
//
// The message is kept deliberately vague for the same reason. "Please set
// up a password" invited the caller to do exactly that; this says where the
// link comes from instead, which is true for the person who belongs here
// and useless to anyone else.
if strings.TrimSpace(dbPassword) == "" {
resp := fiber.Map{
"status": true,
"code": 409,
"message": "Please setup a password.",
"message": "This account has not been set up yet. Use the invitation link that was emailed to you.",
"tenantform": true,
"details": fiber.Map{
"userid": uid,
"setup": true,
"setup": true,
},
}
return models.TenantUserInfo{}, resp, nil
@@ -231,7 +258,7 @@ func (s *userService) AppLogin(user models.User) (models.TenantUserInfo, fiber.M
return info, resp, nil
}
func (s *userService) CreateUser(user models.User) (models.UserInfo, error) {
func (s *userService) CreateUser(user models.User) (models.UserInfo, InviteOutcome, error) {
// Without an authname and a console configid the account is created,
// listed, and then refused at the login screen: `weblogin` matches
// `WHERE authname = ? AND configid = ?` and never looks at the email
@@ -241,16 +268,53 @@ func (s *userService) CreateUser(user models.User) (models.UserInfo, error) {
// Call repository to create user
userid, err := s.repo.CreateUser(user)
if err != nil {
return models.UserInfo{}, err
return models.UserInfo{}, InviteOutcome{}, err
}
// Get user info by id
info, err := s.repo.GetUserById(userid)
if err != nil {
return models.UserInfo{}, err
return models.UserInfo{}, InviteOutcome{}, err
}
return info, nil
// The invitation, after the write and outside it.
//
// This account is created with NO password — nothing on this path sets one —
// and since the sign-in screen stopped offering to set a first password, the
// emailed link is the only way in. Without this the person is added to the
// directory, appears in every branch picker, and cannot sign in, with nothing
// anywhere to say why.
//
// `info.Userid` rather than `userid`: identical, but this is the row that was
// actually read back, so an invitation is never addressed to an id the
// database did not confirm.
return info, s.inviteNewAccount(info, user), nil
}
// inviteNewAccount emails the person who was just hired.
//
// Never an error. A failure is the operator's task — resend, or fix the address —
// and not a reason to unwind a hire that has already happened.
func (s *userService) inviteNewAccount(info models.UserInfo, user models.User) InviteOutcome {
if s.invites == nil {
return InviteOutcome{Reason: "invitations are not configured on this server"}
}
if info.Userid <= 0 {
return InviteOutcome{Reason: "the new account could not be read back to invite it"}
}
// The authname IS the email on a back-office account — `PrepareNewAccount`
// copies one to the other — so this is a fallback for a caller that filled in
// only one of the two, never a second address.
address := strings.TrimSpace(user.Email)
if address == "" {
address = strings.TrimSpace(user.Authname)
}
// Empty business name: the invite service reads it from the tenantid. A staff
// row carries the id and nothing else about the business.
sent, reason := s.invites.Invite(info.Userid, user.Tenantid, address, "")
return InviteOutcome{Sent: sent, Reason: reason}
}
func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, map[string]interface{}) {
@@ -297,16 +361,19 @@ func (s *userService) TenantWebLogin(user models.User) (models.TenantUserInfo, m
}
}
// Step 3: Password checks
// Step 3: Password checks.
//
// The userid is withheld here for the same reason as in `AppLogin` above:
// this branch answers an unauthenticated caller asking about an email, and
// the userid was half of an account takeover. See the long note there.
if strings.TrimSpace(dbPassword) == "" {
return models.TenantUserInfo{}, map[string]interface{}{
"status": true,
"code": 409,
"message": "Please setup a password.",
"message": "This account has not been set up yet. Use the invitation link that was emailed to you.",
"tenantform": tenantFormExists,
"details": map[string]interface{}{
"userid": uid,
"setup": true,
"setup": true,
},
}
}