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)) 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. // ResolveInviteEmail returns the invitee email for a pending, unexpired invite token.
func (s *Service) ResolveInviteEmail(ctx context.Context, token string) (string, error) { func (s *Service) ResolveInviteEmail(ctx context.Context, token string) (string, error) {
token = strings.TrimSpace(token) 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"` User User `json:"user"`
CompanyID uuid.UUID `json:"company_id"` CompanyID uuid.UUID `json:"company_id"`
Companies []Company `json:"companies"` 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) { 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 return LoginResult{}, err
} }
var companyID uuid.UUID companyID, created, err := ensureOwnedCompanyTx(ctx, tx, userID, strings.TrimSpace(in.CompanyName))
err = tx.QueryRow(ctx, `
INSERT INTO companies (name, owner_user_id) VALUES ($1, $2) RETURNING id`,
strings.TrimSpace(in.CompanyName), userID).Scan(&companyID)
if err != nil { if err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
if !created {
_, err = tx.Exec(ctx, ` return LoginResult{}, errors.New("owned company unexpectedly already existed during register")
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 err := tx.Commit(ctx); err != nil { 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{}, err
} }
return LoginResult{ return LoginResult{
User: user, User: user,
CompanyID: companyID, CompanyID: companyID,
Companies: []Company{{ID: companyID, Name: strings.TrimSpace(in.CompanyName)}}, Companies: []Company{{ID: companyID, Name: strings.TrimSpace(in.CompanyName)}},
ProvisionCompanyIDs: []uuid.UUID{companyID},
}, nil }, nil
} }
@@ -228,20 +212,20 @@ func (s *Service) AcceptInvite(ctx context.Context, token, password, name string
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
var userID uuid.UUID var userID uuid.UUID
var existingHash string var existingHash *string
var mustSet bool var mustSet bool
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
SELECT id, password_hash, must_set_password FROM users WHERE email = $1`, SELECT id, password_hash, must_set_password FROM users WHERE email = $1`,
strings.ToLower(email)).Scan(&userID, &existingHash, &mustSet) strings.ToLower(email)).Scan(&userID, &existingHash, &mustSet)
displayName := strings.TrimSpace(name)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
hash, herr := HashPassword(password) hash, herr := HashPassword(password)
if herr != nil { if herr != nil {
return LoginResult{}, herr return LoginResult{}, herr
} }
var n *string var n *string
if strings.TrimSpace(name) != "" { if displayName != "" {
nn := strings.TrimSpace(name) n = &displayName
n = &nn
} }
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, must_set_password) 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 { } else if err != nil {
return LoginResult{}, err return LoginResult{}, err
} else if mustSet { } else {
// Migration / first-password invites may set a password once. var activeMemberships int64
hash, herr := HashPassword(password) if err := tx.QueryRow(ctx, `
if herr != nil { SELECT count(*) FROM memberships
return LoginResult{}, herr WHERE user_id = $1 AND status = 'active'`, userID).Scan(&activeMemberships); err != nil {
}
_, 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 {
return LoginResult{}, err return LoginResult{}, err
} }
} else { // New password when must_set, or orphan recovery (removed from all companies; no SMTP reset).
// Existing accounts keep their password; invitee must prove ownership. if mustSet || activeMemberships == 0 {
ok, verr := VerifyPassword(existingHash, password) hash, herr := HashPassword(password)
if verr != nil || !ok { if herr != nil {
return LoginResult{}, ErrInvalidCredentials 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, ` _, err = tx.Exec(ctx, `
INSERT INTO memberships (company_id, user_id, role, status) INSERT INTO memberships (company_id, user_id, role, status)
VALUES ($1, $2, $3, 'active') VALUES ($1, $2, $3, 'active')
@@ -305,7 +308,11 @@ func (s *Service) AcceptInvite(ctx context.Context, token, password, name string
if err != nil { if err != nil {
return LoginResult{}, err 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 { 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 return
} }
_ = s.Billing.ProvisionFreePlan(r.Context(), res.CompanyID) _ = 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 { if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed") Error(w, http.StatusInternalServerError, "session start failed")
return return
@@ -236,6 +242,14 @@ func (s *Server) handleInvitePreview(w http.ResponseWriter, r *http.Request) {
"valid": true, "valid": true,
"mismatch": false, "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 { if sessionEmail, ok := s.sessionUserEmail(r.Context()); ok {
out["session_email"] = sessionEmail out["session_email"] = sessionEmail
if !auth.EmailsEqual(sessionEmail, inviteEmail) { 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) ClientOrLog(w, http.StatusBadRequest, "invite accept failed", err, auth.ClientError)
return 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 { if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
Error(w, http.StatusInternalServerError, "session start failed") Error(w, http.StatusInternalServerError, "session start failed")
return return
@@ -21,7 +21,7 @@ const a1StyleEnhanceSystemTemplateSL = `Si tekstopisec, ki piše opise izdelkov
Rules: Rules:
- Reply with ONLY JSON (no markdown) - Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}} - 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 - "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 - "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) - "meta_title" and "meta_description" are plain SEO text from the meta / <metaDescription> formula (never HTML body)
+11 -2
View File
@@ -124,17 +124,25 @@ export const en: MessageDict = {
"auth.register.haveAccount": "Already have an account?", "auth.register.haveAccount": "Already have an account?",
"auth.register.signIn": "Sign in", "auth.register.signIn": "Sign in",
"auth.invite.title": "Accept invite", "auth.invite.title": "Accept invite",
"auth.invite.verifyTitle": "Join with your account",
"auth.invite.recoverTitle": "Restore access",
"auth.invite.setPasswordTitle": "Set password", "auth.invite.setPasswordTitle": "Set password",
"auth.invite.description": "Set your password to join the company. Your admin assigned either Member (day-to-day work) or Admin (team and billing).", "auth.invite.description": "Choose a password to join this company. You also get your own workspace you can switch to anytime.",
"auth.invite.verifyDescription": "This email already has a Descrybe account. Enter your current password to join this company. You keep your own workspace and can switch between companies.",
"auth.invite.recoverDescription": "This email has an account with no active company (for example after being removed). Choose a new password to rejoin — you also get your own workspace.",
"auth.invite.setPasswordDescription": "Choose a password for your migrated Descrybe account (at least 8 characters).", "auth.invite.setPasswordDescription": "Choose a password for your migrated Descrybe account (at least 8 characters).",
"auth.invite.checking": "Checking invite…", "auth.invite.checking": "Checking invite…",
"auth.invite.forEmail": "Invite for {email}.", "auth.invite.forEmail": "Invite for {email}.",
"auth.invite.linkRecognized": "Invite link recognized. Enter a password below to continue — the secret is not shown on this page.", "auth.invite.linkRecognized": "Invite link recognized. Enter a password below to continue — the secret is not shown on this page.",
"auth.invite.verifyLinkRecognized": "Invite link recognized. Enter your current account password below — the invite secret is not shown on this page.",
"auth.invite.recoverLinkRecognized": "Invite link recognized. Choose a new password below to restore access — the invite secret is not shown on this page.",
"auth.invite.resetLinkRecognized": "Reset link recognized. Enter a password below to continue — the secret is not shown on this page.", "auth.invite.resetLinkRecognized": "Reset link recognized. Enter a password below to continue — the secret is not shown on this page.",
"auth.invite.tokenLabel": "Invite token", "auth.invite.tokenLabel": "Invite token",
"auth.invite.resetTokenLabel": "Reset token", "auth.invite.resetTokenLabel": "Reset token",
"auth.invite.tokenHelp": "Paste the token from your invite email. It is masked in this field.", "auth.invite.tokenHelp": "Paste the token from your invite email. It is masked in this field.",
"auth.invite.passwordHint": "At least 8 characters. No other complexity rules.", "auth.invite.passwordHint": "At least 8 characters. No other complexity rules.",
"auth.invite.passwordHintVerify": "Use the password you already use to sign in to Descrybe.",
"auth.invite.passwordHintRecover": "At least 8 characters. This replaces your previous password.",
"auth.invite.submit": "Accept invite", "auth.invite.submit": "Accept invite",
"auth.invite.setPasswordSubmit": "Set password", "auth.invite.setPasswordSubmit": "Set password",
"auth.invite.accepting": "Accepting…", "auth.invite.accepting": "Accepting…",
@@ -143,6 +151,7 @@ export const en: MessageDict = {
"auth.invite.verifySetPasswordFailed": "Could not verify set-password link", "auth.invite.verifySetPasswordFailed": "Could not verify set-password link",
"auth.invite.acceptFailed": "Could not accept invite", "auth.invite.acceptFailed": "Could not accept invite",
"auth.invite.setPasswordFailed": "Could not set password", "auth.invite.setPasswordFailed": "Could not set password",
"auth.invite.wrongPassword": "That password does not match this account. Enter the password you use to sign in, or ask an admin for a new invite if you were removed and need to set a new password.",
"auth.invite.expired": "This invite is invalid or expired. Ask your company admin to send a new invite, then open the new link (or paste the new token below).", "auth.invite.expired": "This invite is invalid or expired. Ask your company admin to send a new invite, then open the new link (or paste the new token below).",
"auth.invite.setPasswordExpired": "This set-password link is invalid or expired. Ask a company admin to re-issue a set-password link to this email, then open the new link (or paste the new token below).", "auth.invite.setPasswordExpired": "This set-password link is invalid or expired. Ask a company admin to re-issue a set-password link to this email, then open the new link (or paste the new token below).",
"auth.invite.emailMismatchDefault": "You're signed in as a different email than this invite.", "auth.invite.emailMismatchDefault": "You're signed in as a different email than this invite.",
@@ -150,7 +159,7 @@ export const en: MessageDict = {
"auth.invite.adminUsersLink": "Admin → Users", "auth.invite.adminUsersLink": "Admin → Users",
"auth.invite.doneTitle": "You're on the team", "auth.invite.doneTitle": "You're on the team",
"auth.invite.doneSetPasswordTitle": "Password saved", "auth.invite.doneSetPasswordTitle": "Password saved",
"auth.invite.doneDescription": "Your account is ready. Next, open the dashboard to work with feeds and products, or review company settings.", "auth.invite.doneDescription": "You're on the invited team and have your own workspace. Switch companies anytime from the header.",
"auth.invite.doneSetPasswordDescription": "Sign in with your email and new password to open your workspace.", "auth.invite.doneSetPasswordDescription": "Sign in with your email and new password to open your workspace.",
"auth.invite.openDashboard": "Open dashboard", "auth.invite.openDashboard": "Open dashboard",
"auth.invite.companySettings": "Company settings", "auth.invite.companySettings": "Company settings",
+39 -5
View File
@@ -25,6 +25,7 @@
session_email?: string; session_email?: string;
valid: boolean; valid: boolean;
mismatch: boolean; mismatch: boolean;
password_mode?: "set" | "verify" | "recover";
}; };
let token = $state(""); let token = $state("");
@@ -44,6 +45,8 @@
let signingOut = $state(false); let signingOut = $state(false);
/** Admin HMAC emails use mode=set-password; migrator invites use the invite token path. */ /** Admin HMAC emails use mode=set-password; migrator invites use the invite token path. */
let mode = $state<"invite" | "set-password">("invite"); let mode = $state<"invite" | "set-password">("invite");
/** How accept-invite treats the password field for an existing/new account. */
let passwordMode = $state<"set" | "verify" | "recover">("set");
const errorId = "accept-invite-form-error"; const errorId = "accept-invite-form-error";
const passwordHintId = "accept-invite-password-hint"; const passwordHintId = "accept-invite-password-hint";
@@ -84,10 +87,20 @@
if (preview.mode === "set-password" || preview.mode === "invite") { if (preview.mode === "set-password" || preview.mode === "invite") {
mode = preview.mode; mode = preview.mode;
} }
if (
preview.password_mode === "set" ||
preview.password_mode === "verify" ||
preview.password_mode === "recover"
) {
passwordMode = preview.password_mode;
} else if (preview.mode === "invite") {
passwordMode = "set";
}
} catch (err) { } catch (err) {
mismatch = false; mismatch = false;
inviteEmail = ""; inviteEmail = "";
sessionEmail = ""; sessionEmail = "";
passwordMode = "set";
error = error =
err instanceof ApiError err instanceof ApiError
? err.message ? err.message
@@ -142,6 +155,7 @@
done = true; done = true;
} catch (err) { } catch (err) {
if (applyMismatchFromError(err)) return; if (applyMismatchFromError(err)) return;
const code = apiErrorCode(err);
const raw = err instanceof ApiError ? err.message : ""; const raw = err instanceof ApiError ? err.message : "";
const expired = const expired =
/invalid or expired/i.test(raw) || /token invalid or expired/i.test(raw); /invalid or expired/i.test(raw) || /token invalid or expired/i.test(raw);
@@ -150,6 +164,8 @@
mode === "set-password" mode === "set-password"
? i18n.t("auth.invite.setPasswordExpired") ? i18n.t("auth.invite.setPasswordExpired")
: i18n.t("auth.invite.expired"); : i18n.t("auth.invite.expired");
} else if (code === "invalid_credentials" && mode === "invite") {
error = i18n.t("auth.invite.wrongPassword");
} else { } else {
error = error =
raw || raw ||
@@ -274,12 +290,20 @@
<CardTitle level={1} <CardTitle level={1}
>{mode === "set-password" >{mode === "set-password"
? i18n.t("auth.invite.setPasswordTitle") ? i18n.t("auth.invite.setPasswordTitle")
: i18n.t("auth.invite.title")}</CardTitle : passwordMode === "verify"
? i18n.t("auth.invite.verifyTitle")
: passwordMode === "recover"
? i18n.t("auth.invite.recoverTitle")
: i18n.t("auth.invite.title")}</CardTitle
> >
<CardDescription> <CardDescription>
{mode === "set-password" {mode === "set-password"
? i18n.t("auth.invite.setPasswordDescription") ? i18n.t("auth.invite.setPasswordDescription")
: i18n.t("auth.invite.description")} : passwordMode === "verify"
? i18n.t("auth.invite.verifyDescription")
: passwordMode === "recover"
? i18n.t("auth.invite.recoverDescription")
: i18n.t("auth.invite.description")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -310,7 +334,11 @@
<p class="rounded-md border bg-muted/40 px-3 py-2 text-sm text-text-muted"> <p class="rounded-md border bg-muted/40 px-3 py-2 text-sm text-text-muted">
{mode === "set-password" {mode === "set-password"
? i18n.t("auth.invite.resetLinkRecognized") ? i18n.t("auth.invite.resetLinkRecognized")
: i18n.t("auth.invite.linkRecognized")} : passwordMode === "verify"
? i18n.t("auth.invite.verifyLinkRecognized")
: passwordMode === "recover"
? i18n.t("auth.invite.recoverLinkRecognized")
: i18n.t("auth.invite.linkRecognized")}
</p> </p>
{:else} {:else}
<div class="space-y-2"> <div class="space-y-2">
@@ -359,7 +387,9 @@
id="password" id="password"
type="password" type="password"
name="password" name="password"
autocomplete="new-password" autocomplete={mode === "invite" && passwordMode === "verify"
? "current-password"
: "new-password"}
required required
minlength={8} minlength={8}
aria-invalid={error ? "true" : undefined} aria-invalid={error ? "true" : undefined}
@@ -367,7 +397,11 @@
bind:value={password} bind:value={password}
/> />
<p id={passwordHintId} class="text-xs text-text-muted"> <p id={passwordHintId} class="text-xs text-text-muted">
{i18n.t("auth.invite.passwordHint")} {mode === "invite" && passwordMode === "verify"
? i18n.t("auth.invite.passwordHintVerify")
: mode === "invite" && passwordMode === "recover"
? i18n.t("auth.invite.passwordHintRecover")
: i18n.t("auth.invite.passwordHint")}
</p> </p>
</div> </div>