This commit is contained in:
2026-08-24 04:03:44 +02:00
parent 8a690a0464
commit 7bafd7a322
9 changed files with 391 additions and 53 deletions
@@ -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)
}
}
+40
View File
@@ -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)
+83
View File
@@ -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")
}
}
+52 -45
View File
@@ -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)