diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 9da3bc5..2b10fc8 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -122,21 +122,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string // ProvisionEmail returns the account owning the email identity externalID, creating // it on first contact with browserTZ — the client's detected "±HH:MM" UTC offset — -// seeded into its time zone and language seeded from the client's UI language. Like +// seeded into its time zone, language seeded from the client's UI language, and its +// display name seeded from the email's local part (so it is not left nameless). Like // ProvisionByIdentity it is race-safe and leaves an existing account untouched, so a -// returning user's saved zone and language are never overwritten. The email account is +// returning user's saved zone, language and name are never overwritten. The email account is // created here (the code-request step), not at the later login, so this is where its // zone and language are seeded. It is created flagged is_guest with an unconfirmed // email identity: an abandoned, never-confirmed login is then reaped like any guest, // freeing the reserved address, and confirming the code clears the guest flag. func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ, language string) (Account, error) { return s.provision(ctx, KindEmail, externalID, provisionSeed{ + displayName: emailDisplayName(externalID), timeZone: seedZone(browserTZ), preferredLanguage: supportedLanguage(language), isGuest: true, }) } +// emailDisplayName derives a display name from an email address — the local part +// before '@', trimmed and capped to the column width — so a new email account is not +// left nameless. It is only the first-contact seed; the user can rename it later. +func emailDisplayName(email string) string { + local, _, _ := strings.Cut(email, "@") + local = strings.TrimSpace(local) + if r := []rune(local); len(r) > maxDisplayName { + local = strings.TrimRight(string(r[:maxDisplayName]), " ") + } + return local +} + // supportedLanguage returns code normalised to a supported UI language ("en" or // "ru"), or "" when it maps to neither, so a new account keeps the 'en' default. It // accepts region-tagged codes ("ru-RU"). diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 8937057..ea3940b 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -373,3 +373,24 @@ func TestConfirmByTokenLinkMerge(t *testing.T) { t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner) } } + +// TestEmailAccountSeedsDisplayName seeds a new email account's display name from the +// email's local part, so an email login is not left nameless. +func TestEmailAccountSeedsDisplayName(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru") + local := "kaya-" + uuid.NewString()[:8] + + id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en") + if err != nil { + t.Fatalf("request login: %v", err) + } + acc, err := store.GetByID(ctx, id) + if err != nil { + t.Fatalf("get: %v", err) + } + if acc.DisplayName != local { + t.Errorf("display name = %q, want the email local part %q", acc.DisplayName, local) + } +}