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)
+11 -2
View File
@@ -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",
+39 -5
View File
@@ -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 @@
<CardTitle level={1}
>{mode === "set-password"
? 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>
{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")}
</CardDescription>
</CardHeader>
<CardContent>
@@ -310,7 +334,11 @@
<p class="rounded-md border bg-muted/40 px-3 py-2 text-sm text-text-muted">
{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")}
</p>
{:else}
<div class="space-y-2">
@@ -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}
/>
<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>
</div>