fix
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestAcceptInviteOrphanRecoversPasswordAndOwnsCompany covers the removed-member
|
||||
// re-invite path: zero active memberships may set a new password, and accept also
|
||||
// provisions a personal owned workspace alongside the invited company.
|
||||
func TestAcceptInviteOrphanRecoversPasswordAndOwnsCompany(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
prefix := uuid.New().String()[:8]
|
||||
inviteCompanyID := uuid.New()
|
||||
userID := uuid.New()
|
||||
email := fmt.Sprintf("orphan-invite-%s@example.test", prefix)
|
||||
oldHash, err := HashPassword("OldPassword123!")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
inviteCompanyID, "Invite Target "+prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("seed invite company: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
|
||||
VALUES ($1, $2, $3, $4, false, false, true)`,
|
||||
userID, email, "Orphan", oldHash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// Inactive membership only — mimics remove-member.
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, 'member', 'inactive')`, inviteCompanyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed inactive membership: %v", err)
|
||||
}
|
||||
|
||||
mode, err := svc.InvitePasswordMode(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("InvitePasswordMode: %v", err)
|
||||
}
|
||||
if mode != InvitePasswordRecover {
|
||||
t.Fatalf("password mode=%q want %q", mode, InvitePasswordRecover)
|
||||
}
|
||||
|
||||
inv, token, err := svc.CreateInvite(ctx, inviteCompanyID, userID, email, "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInvite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cctx := t.Context()
|
||||
_, _ = pg.Exec(cctx, `DELETE FROM invites WHERE id = $1`, inv.ID)
|
||||
_, _ = pg.Exec(cctx, `DELETE FROM memberships WHERE user_id = $1`, userID)
|
||||
_, _ = pg.Exec(cctx, `DELETE FROM credit_balances WHERE company_id IN (SELECT id FROM companies WHERE owner_user_id = $1)`, userID)
|
||||
_, _ = pg.Exec(cctx, `DELETE FROM company_settings WHERE company_id IN (SELECT id FROM companies WHERE owner_user_id = $1)`, userID)
|
||||
_, _ = pg.Exec(cctx, `DELETE FROM company_plans WHERE company_id IN (SELECT id FROM companies WHERE owner_user_id = $1)`, userID)
|
||||
_, _ = pg.Exec(cctx, `DELETE FROM companies WHERE owner_user_id = $1 OR id = $2`, userID, inviteCompanyID)
|
||||
_, _ = pg.Exec(cctx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
res, err := svc.AcceptInvite(ctx, token, "NewPassword123!", "Orphan")
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptInvite: %v", err)
|
||||
}
|
||||
if res.CompanyID != inviteCompanyID {
|
||||
t.Fatalf("active company=%s want invited %s", res.CompanyID, inviteCompanyID)
|
||||
}
|
||||
if len(res.ProvisionCompanyIDs) != 1 {
|
||||
t.Fatalf("expected one newly provisioned owned company, got %#v", res.ProvisionCompanyIDs)
|
||||
}
|
||||
ownedID := res.ProvisionCompanyIDs[0]
|
||||
if ownedID == inviteCompanyID {
|
||||
t.Fatal("owned company must differ from invited company")
|
||||
}
|
||||
|
||||
var owner uuid.UUID
|
||||
if err := pg.QueryRow(ctx, `SELECT owner_user_id FROM companies WHERE id = $1`, ownedID).Scan(&owner); err != nil {
|
||||
t.Fatalf("owned company: %v", err)
|
||||
}
|
||||
if owner != userID {
|
||||
t.Fatalf("owner=%s want %s", owner, userID)
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT count(*) FROM memberships WHERE user_id = $1 AND status = 'active'`, userID).Scan(&activeCount); err != nil {
|
||||
t.Fatalf("active memberships: %v", err)
|
||||
}
|
||||
if activeCount < 2 {
|
||||
t.Fatalf("active memberships=%d want at least 2 (owned + invited)", activeCount)
|
||||
}
|
||||
|
||||
if _, err := svc.Login(ctx, email, "NewPassword123!"); err != nil {
|
||||
t.Fatalf("login with new password: %v", err)
|
||||
}
|
||||
if _, err := svc.Login(ctx, email, "OldPassword123!"); err != ErrInvalidCredentials {
|
||||
t.Fatalf("old password should fail: err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,46 @@ func EmailsEqual(a, b string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
|
||||
}
|
||||
|
||||
// Invite password modes for accept-invite UX (also drives AcceptInvite branching).
|
||||
const (
|
||||
InvitePasswordSet = "set" // new account — choose a password
|
||||
InvitePasswordVerify = "verify" // existing account with active memberships — current password
|
||||
InvitePasswordRecover = "recover" // existing account with no active memberships — set a new password
|
||||
)
|
||||
|
||||
// InvitePasswordMode reports how accept-invite should treat the password field for email.
|
||||
func (s *Service) InvitePasswordMode(ctx context.Context, email string) (string, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return InvitePasswordSet, nil
|
||||
}
|
||||
var (
|
||||
userID uuid.UUID
|
||||
mustSet bool
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, must_set_password FROM users WHERE email = $1`, email).Scan(&userID, &mustSet)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return InvitePasswordSet, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if mustSet {
|
||||
return InvitePasswordSet, nil
|
||||
}
|
||||
var active int64
|
||||
if err := s.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM memberships
|
||||
WHERE user_id = $1 AND status = 'active'`, userID).Scan(&active); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if active == 0 {
|
||||
return InvitePasswordRecover, nil
|
||||
}
|
||||
return InvitePasswordVerify, nil
|
||||
}
|
||||
|
||||
// ResolveInviteEmail returns the invitee email for a pending, unexpired invite token.
|
||||
func (s *Service) ResolveInviteEmail(ctx context.Context, token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// defaultOwnedCompanyName builds a workspace name when invite accept creates a personal company.
|
||||
func defaultOwnedCompanyName(email, name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
return name + "'s workspace"
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
local := email
|
||||
if at := strings.IndexByte(email, '@'); at > 0 {
|
||||
local = email[:at]
|
||||
}
|
||||
local = strings.TrimSpace(local)
|
||||
if local == "" {
|
||||
return "My workspace"
|
||||
}
|
||||
return local + "'s workspace"
|
||||
}
|
||||
|
||||
// ensureOwnedCompanyTx returns the user's owned company, creating one (admin membership +
|
||||
// settings + credit wallet) when they do not already own a tenant.
|
||||
func ensureOwnedCompanyTx(ctx context.Context, tx pgx.Tx, userID uuid.UUID, companyName string) (uuid.UUID, bool, error) {
|
||||
var existing uuid.UUID
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM companies WHERE owner_user_id = $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`, userID).Scan(&existing)
|
||||
if err == nil {
|
||||
_, merr := tx.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, 'admin', 'active')
|
||||
ON CONFLICT (company_id, user_id) DO UPDATE
|
||||
SET status = 'active',
|
||||
role = 'admin',
|
||||
updated_at = now()`, existing, userID)
|
||||
return existing, false, merr
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
|
||||
companyName = strings.TrimSpace(companyName)
|
||||
if companyName == "" {
|
||||
companyName = "My workspace"
|
||||
}
|
||||
|
||||
var companyID uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO companies (name, owner_user_id) VALUES ($1, $2) RETURNING id`,
|
||||
companyName, userID).Scan(&companyID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO company_settings (company_id) VALUES ($1)
|
||||
ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO credit_balances (company_id) VALUES ($1)
|
||||
ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
return companyID, true, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultOwnedCompanyName(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
email, name, want string
|
||||
}{
|
||||
{email: "ada@example.com", name: "Ada", want: "Ada's workspace"},
|
||||
{email: "ada@example.com", name: " ", want: "ada's workspace"},
|
||||
{email: "ada@example.com", name: "", want: "ada's workspace"},
|
||||
{email: "", name: "", want: "My workspace"},
|
||||
{email: "nolsocal", name: "", want: "nolsocal's workspace"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := defaultOwnedCompanyName(tc.email, tc.name)
|
||||
if got != tc.want {
|
||||
t.Fatalf("defaultOwnedCompanyName(%q, %q)=%q want %q", tc.email, tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitePasswordModeConstants(t *testing.T) {
|
||||
t.Parallel()
|
||||
if InvitePasswordSet != "set" || InvitePasswordVerify != "verify" || InvitePasswordRecover != "recover" {
|
||||
t.Fatal("invite password mode constants drifted")
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ type LoginResult struct {
|
||||
User User `json:"user"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
Companies []Company `json:"companies"`
|
||||
// ProvisionCompanyIDs are newly created tenants that still need Free-plan billing setup.
|
||||
ProvisionCompanyIDs []uuid.UUID `json:"-"`
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, in RegisterInput) (LoginResult, error) {
|
||||
@@ -100,31 +102,12 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (LoginResult,
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
var companyID uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO companies (name, owner_user_id) VALUES ($1, $2) RETURNING id`,
|
||||
strings.TrimSpace(in.CompanyName), userID).Scan(&companyID)
|
||||
companyID, created, err := ensureOwnedCompanyTx(ctx, tx, userID, strings.TrimSpace(in.CompanyName))
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO company_settings (company_id) VALUES ($1)
|
||||
ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO credit_balances (company_id) VALUES ($1)
|
||||
ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
if !created {
|
||||
return LoginResult{}, errors.New("owned company unexpectedly already existed during register")
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
@@ -136,9 +119,10 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (LoginResult,
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{
|
||||
User: user,
|
||||
CompanyID: companyID,
|
||||
Companies: []Company{{ID: companyID, Name: strings.TrimSpace(in.CompanyName)}},
|
||||
User: user,
|
||||
CompanyID: companyID,
|
||||
Companies: []Company{{ID: companyID, Name: strings.TrimSpace(in.CompanyName)}},
|
||||
ProvisionCompanyIDs: []uuid.UUID{companyID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -228,20 +212,20 @@ func (s *Service) AcceptInvite(ctx context.Context, token, password, name string
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var userID uuid.UUID
|
||||
var existingHash string
|
||||
var existingHash *string
|
||||
var mustSet bool
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, password_hash, must_set_password FROM users WHERE email = $1`,
|
||||
strings.ToLower(email)).Scan(&userID, &existingHash, &mustSet)
|
||||
displayName := strings.TrimSpace(name)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
hash, herr := HashPassword(password)
|
||||
if herr != nil {
|
||||
return LoginResult{}, herr
|
||||
}
|
||||
var n *string
|
||||
if strings.TrimSpace(name) != "" {
|
||||
nn := strings.TrimSpace(name)
|
||||
n = &nn
|
||||
if displayName != "" {
|
||||
n = &displayName
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, must_set_password)
|
||||
@@ -251,26 +235,45 @@ func (s *Service) AcceptInvite(ctx context.Context, token, password, name string
|
||||
}
|
||||
} else if err != nil {
|
||||
return LoginResult{}, err
|
||||
} else if mustSet {
|
||||
// Migration / first-password invites may set a password once.
|
||||
hash, herr := HashPassword(password)
|
||||
if herr != nil {
|
||||
return LoginResult{}, herr
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND must_set_password = true`, userID, hash)
|
||||
if err != nil {
|
||||
} else {
|
||||
var activeMemberships int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM memberships
|
||||
WHERE user_id = $1 AND status = 'active'`, userID).Scan(&activeMemberships); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
} else {
|
||||
// Existing accounts keep their password; invitee must prove ownership.
|
||||
ok, verr := VerifyPassword(existingHash, password)
|
||||
if verr != nil || !ok {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
// New password when must_set, or orphan recovery (removed from all companies; no SMTP reset).
|
||||
if mustSet || activeMemberships == 0 {
|
||||
hash, herr := HashPassword(password)
|
||||
if herr != nil {
|
||||
return LoginResult{}, herr
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1`, userID, hash)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
} else {
|
||||
// Active elsewhere: keep password; invitee must prove ownership.
|
||||
hash := ""
|
||||
if existingHash != nil {
|
||||
hash = *existingHash
|
||||
}
|
||||
ok, verr := VerifyPassword(hash, password)
|
||||
if verr != nil || !ok {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure a personal owned workspace; invite also joins the inviting company.
|
||||
ownedName := defaultOwnedCompanyName(email, displayName)
|
||||
ownedID, ownedCreated, err := ensureOwnedCompanyTx(ctx, tx, userID, ownedName)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, $3, 'active')
|
||||
@@ -305,7 +308,11 @@ func (s *Service) AcceptInvite(ctx context.Context, token, password, name string
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{User: user, CompanyID: companyID, Companies: companies}, nil
|
||||
res := LoginResult{User: user, CompanyID: companyID, Companies: companies}
|
||||
if ownedCreated {
|
||||
res.ProvisionCompanyIDs = []uuid.UUID{ownedID}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetPassword(ctx context.Context, userID uuid.UUID, password string) error {
|
||||
|
||||
@@ -50,6 +50,12 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
_ = s.Billing.ProvisionFreePlan(r.Context(), res.CompanyID)
|
||||
for _, cid := range res.ProvisionCompanyIDs {
|
||||
if cid == res.CompanyID {
|
||||
continue
|
||||
}
|
||||
_ = s.Billing.ProvisionFreePlan(r.Context(), cid)
|
||||
}
|
||||
if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "session start failed")
|
||||
return
|
||||
@@ -236,6 +242,14 @@ func (s *Server) handleInvitePreview(w http.ResponseWriter, r *http.Request) {
|
||||
"valid": true,
|
||||
"mismatch": false,
|
||||
}
|
||||
if mode == "invite" {
|
||||
pwMode, err := s.Auth.InvitePasswordMode(r.Context(), inviteEmail)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "invite preview failed", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
out["password_mode"] = pwMode
|
||||
}
|
||||
if sessionEmail, ok := s.sessionUserEmail(r.Context()); ok {
|
||||
out["session_email"] = sessionEmail
|
||||
if !auth.EmailsEqual(sessionEmail, inviteEmail) {
|
||||
@@ -285,6 +299,9 @@ func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ClientOrLog(w, http.StatusBadRequest, "invite accept failed", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
for _, cid := range res.ProvisionCompanyIDs {
|
||||
_ = s.Billing.ProvisionFreePlan(r.Context(), cid)
|
||||
}
|
||||
if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "session start failed")
|
||||
return
|
||||
|
||||
@@ -21,7 +21,7 @@ const a1StyleEnhanceSystemTemplateSL = `Si tekstopisec, ki piše opise izdelkov
|
||||
Rules:
|
||||
- Reply with ONLY JSON (no markdown)
|
||||
- Schema: {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}}
|
||||
- Obey the GPT predloga in the user message step by step (same behavior as legacy A1 category Prompt)
|
||||
- Obey the GPT predloga in the user message step by step
|
||||
- "name" follows the <name> Title formula — never copy Staro_ime_izdelka unchanged when the formula asks for a new name
|
||||
- "description" is ONE HTML string covering each GPT predloga body section in order (tags matching type: h1/h2/h3/h4, p, ul) — do NOT wrap the reply in <name> or <metaDescription> tags
|
||||
- "meta_title" and "meta_description" are plain SEO text from the meta / <metaDescription> formula (never HTML body)
|
||||
|
||||
Reference in New Issue
Block a user