From 7bafd7a322173f482ebb1826cea4eb101d0780f7 Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Mon, 24 Aug 2026 04:03:44 +0200 Subject: [PATCH] fix --- .../auth/accept_invite_integration_test.go | 119 ++++++++++++++++++ apps/api/internal/auth/invites.go | 40 ++++++ apps/api/internal/auth/owned_company.go | 83 ++++++++++++ apps/api/internal/auth/owned_company_test.go | 29 +++++ apps/api/internal/auth/service.go | 97 +++++++------- apps/api/internal/httpapi/auth_handlers.go | 17 +++ .../internal/processing/a1_enhance_prompt.go | 2 +- apps/web/src/lib/i18n/messages/en.ts | 13 +- .../web/src/routes/accept-invite/+page.svelte | 44 ++++++- 9 files changed, 391 insertions(+), 53 deletions(-) create mode 100644 apps/api/internal/auth/accept_invite_integration_test.go create mode 100644 apps/api/internal/auth/owned_company.go create mode 100644 apps/api/internal/auth/owned_company_test.go diff --git a/apps/api/internal/auth/accept_invite_integration_test.go b/apps/api/internal/auth/accept_invite_integration_test.go new file mode 100644 index 0000000..692ab64 --- /dev/null +++ b/apps/api/internal/auth/accept_invite_integration_test.go @@ -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) + } +} diff --git a/apps/api/internal/auth/invites.go b/apps/api/internal/auth/invites.go index f042fe2..ee091cd 100644 --- a/apps/api/internal/auth/invites.go +++ b/apps/api/internal/auth/invites.go @@ -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) diff --git a/apps/api/internal/auth/owned_company.go b/apps/api/internal/auth/owned_company.go new file mode 100644 index 0000000..42e350b --- /dev/null +++ b/apps/api/internal/auth/owned_company.go @@ -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 +} diff --git a/apps/api/internal/auth/owned_company_test.go b/apps/api/internal/auth/owned_company_test.go new file mode 100644 index 0000000..1271bcb --- /dev/null +++ b/apps/api/internal/auth/owned_company_test.go @@ -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") + } +} diff --git a/apps/api/internal/auth/service.go b/apps/api/internal/auth/service.go index 2d3f3a5..c69e272 100644 --- a/apps/api/internal/auth/service.go +++ b/apps/api/internal/auth/service.go @@ -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 { diff --git a/apps/api/internal/httpapi/auth_handlers.go b/apps/api/internal/httpapi/auth_handlers.go index ab2111a..f648156 100644 --- a/apps/api/internal/httpapi/auth_handlers.go +++ b/apps/api/internal/httpapi/auth_handlers.go @@ -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 diff --git a/apps/api/internal/processing/a1_enhance_prompt.go b/apps/api/internal/processing/a1_enhance_prompt.go index 8e9dae4..8ae4a51 100644 --- a/apps/api/internal/processing/a1_enhance_prompt.go +++ b/apps/api/internal/processing/a1_enhance_prompt.go @@ -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 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 or tags - "meta_title" and "meta_description" are plain SEO text from the meta / formula (never HTML body) diff --git a/apps/web/src/lib/i18n/messages/en.ts b/apps/web/src/lib/i18n/messages/en.ts index a759d60..71c588a 100644 --- a/apps/web/src/lib/i18n/messages/en.ts +++ b/apps/web/src/lib/i18n/messages/en.ts @@ -124,17 +124,25 @@ export const en: MessageDict = { "auth.register.haveAccount": "Already have an account?", "auth.register.signIn": "Sign in", "auth.invite.title": "Accept invite", + "auth.invite.verifyTitle": "Join with your account", + "auth.invite.recoverTitle": "Restore access", "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.checking": "Checking invite…", "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.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.tokenLabel": "Invite token", "auth.invite.resetTokenLabel": "Reset token", "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.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.setPasswordSubmit": "Set password", "auth.invite.accepting": "Accepting…", @@ -143,6 +151,7 @@ export const en: MessageDict = { "auth.invite.verifySetPasswordFailed": "Could not verify set-password link", "auth.invite.acceptFailed": "Could not accept invite", "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.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.", @@ -150,7 +159,7 @@ export const en: MessageDict = { "auth.invite.adminUsersLink": "Admin → Users", "auth.invite.doneTitle": "You're on the team", "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.openDashboard": "Open dashboard", "auth.invite.companySettings": "Company settings", diff --git a/apps/web/src/routes/accept-invite/+page.svelte b/apps/web/src/routes/accept-invite/+page.svelte index 893cb0c..47b9fb8 100644 --- a/apps/web/src/routes/accept-invite/+page.svelte +++ b/apps/web/src/routes/accept-invite/+page.svelte @@ -25,6 +25,7 @@ session_email?: string; valid: boolean; mismatch: boolean; + password_mode?: "set" | "verify" | "recover"; }; let token = $state(""); @@ -44,6 +45,8 @@ let signingOut = $state(false); /** Admin HMAC emails use mode=set-password; migrator invites use the invite token path. */ 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 passwordHintId = "accept-invite-password-hint"; @@ -84,10 +87,20 @@ if (preview.mode === "set-password" || preview.mode === "invite") { 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) { mismatch = false; inviteEmail = ""; sessionEmail = ""; + passwordMode = "set"; error = err instanceof ApiError ? err.message @@ -142,6 +155,7 @@ done = true; } catch (err) { if (applyMismatchFromError(err)) return; + const code = apiErrorCode(err); const raw = err instanceof ApiError ? err.message : ""; const expired = /invalid or expired/i.test(raw) || /token invalid or expired/i.test(raw); @@ -150,6 +164,8 @@ mode === "set-password" ? i18n.t("auth.invite.setPasswordExpired") : i18n.t("auth.invite.expired"); + } else if (code === "invalid_credentials" && mode === "invite") { + error = i18n.t("auth.invite.wrongPassword"); } else { error = raw || @@ -274,12 +290,20 @@ {mode === "set-password" ? i18n.t("auth.invite.setPasswordTitle") - : i18n.t("auth.invite.title")} {mode === "set-password" ? 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")} @@ -310,7 +334,11 @@

{mode === "set-password" ? 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")}

{:else}
@@ -359,7 +387,9 @@ id="password" type="password" name="password" - autocomplete="new-password" + autocomplete={mode === "invite" && passwordMode === "verify" + ? "current-password" + : "new-password"} required minlength={8} aria-invalid={error ? "true" : undefined} @@ -367,7 +397,11 @@ bind:value={password} />

- {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")}