diff --git a/cmd/auth.go b/cmd/auth.go index 4fe704f..613a74a 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -115,6 +115,8 @@ func runLogin(cmd *cobra.Command, args []string) error { fmt.Println() fmt.Println("Copy the link out of the email. Don't open it in your browser first:") fmt.Println("the link works once, and the browser would spend it.") + fmt.Printf("It also expires %s after it is sent — an email that turns up late\n", auth.LinkValidity) + fmt.Println("turns up dead, so if it has not arrived, run this command again.") fmt.Println() fmt.Print("Paste the link: ") diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1dabbda..ace9c59 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -11,6 +11,7 @@ package auth import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -25,6 +26,13 @@ const ( // The domain the platform sends its sign-in links from. Subdomains count: // the link lands on the web app, the API lives next door. linkDomain = "nan.builders" + + // How long a sign-in link works for, in the platform's own words on its + // login page: "It expires in 15 minutes and can only be used once." This + // flow spends long enough waiting for a member to fetch a link out of an + // inbox that the number is worth saying: an email that turns up late turns + // up dead, and the paste prompt cannot tell anybody that. + LinkValidity = "15 minutes" ) // A timeout, because http.DefaultClient has none: a connection that is @@ -121,8 +129,27 @@ func ExchangeToken(token string) (string, error) { // with the reason in the query, which is more useful than the status code. if location, err := resp.Location(); err == nil { if reason := location.Query().Get("reason"); reason != "" { - return "", fmt.Errorf("the link did not work: %s", strings.ReplaceAll(reason, "_", " ")) + return "", errors.New(linkReasonMessage(reason)) } } return "", fmt.Errorf("no session came back (HTTP %d)", resp.StatusCode) } + +// The platform's reasons, in the query of the page it redirects a refused link +// to. Two of them have something the member can do about it, and both were +// arriving here as a slug with the underscores taken out: `invalid_link` covers +// a link that expired and a link somebody else already spent, and named +// neither, which left the person this happens to - the one whose email turned +// up late - with nothing to do but paste the same dead link again. +var linkReasons = map[string]string{ + "invalid_link": "that link expired or was already used — a link works once and " + + "expires " + LinkValidity + " after it is sent, so ask for another one", + "missing_token": "that link is incomplete — copy the whole link out of the most recent email", +} + +func linkReasonMessage(reason string) string { + if message, ok := linkReasons[reason]; ok { + return message + } + return "the link did not work: " + strings.ReplaceAll(reason, "_", " ") +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index c3223ff..4070f93 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -69,3 +69,27 @@ func TestLinkErrorsDoNotEchoTheLink(t *testing.T) { } } } + +// A link that expired and a link somebody already spent arrive here as the same +// slug, and the member on the other end of it is the one whose email turned up +// late. The answer has to name the clock and what to do about it, or the only +// thing left to try is pasting the same dead link again. A reason from a newer +// platform than this build still gets said, rather than swallowed. +func TestRefusedLinkSaysWhatToDoAboutIt(t *testing.T) { + for _, c := range []struct { + name string + reason string + want string + }{ + {"expired or already spent", "invalid_link", "15 minutes"}, + {"incomplete", "missing_token", "most recent email"}, + {"a reason this build does not know", "a_reason_from_tomorrow", "a reason from tomorrow"}, + } { + t.Run(c.name, func(t *testing.T) { + got := linkReasonMessage(c.reason) + if !strings.Contains(got, c.want) { + t.Errorf("linkReasonMessage(%q) = %q, want it to mention %q", c.reason, got, c.want) + } + }) + } +} diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index ab28f14..3285d8d 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -1419,6 +1419,11 @@ func TestHomeDropsTheGuideWhenSetupIsDone(t *testing.T) { // prompts on stdin, start the panel again. Reported twice from a real machine, // stuck at different steps of it. The panel owns the keyboard already, so it // asks the same two questions itself. +// +// It asks View, and not a renderer of its own, because the login screen this +// replaced stopped being drawn when the wizard arrived: the test went on +// passing against a function nothing called, which is the one way a test can be +// green and wrong at the same time. func TestSigningInHappensInsideThePanel(t *testing.T) { m := setupModel(t, &session.Session{}) @@ -1430,19 +1435,26 @@ func TestSigningInHappensInsideThePanel(t *testing.T) { t.Fatal("s does not start the sign-in") } - out := m.renderLogin(newLayout(90, 24)) - for _, want := range []string{"Sign in", "Step 1 of 2", "Email"} { + out := m.View() + for _, want := range []string{"Let's get you logged in", "Email"} { if !strings.Contains(out, want) { - t.Errorf("the first step does not show %q", want) + t.Errorf("the first step does not show %q:\n%s", want, out) } } - // Second question, once the link is on its way. - m.loginStage = loginAskLink - m.loginInput.Prompt = "Paste the link: " - out = m.renderLogin(newLayout(90, 24)) - if !strings.Contains(out, "Step 2 of 2") || !strings.Contains(out, "Paste the link") { - t.Errorf("the second step does not ask for the link:\n%s", out) + // Second question, once the link is on its way, driven through the message + // the flow actually sends rather than by setting the stage by hand: that is + // what makes this fail when the two halves drift apart. + mod, _ := m.Update(linkSentMsg{}) + m = mod.(model) + if m.loginStage != loginAskLink { + t.Fatal("an accepted request does not move on to the link") + } + out = m.View() + for _, want := range []string{"Let's confirm it with the magic link", "Paste the link", "15 minutes"} { + if !strings.Contains(out, want) { + t.Errorf("the second step does not ask for the link:\n%s", out) + } } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index b71f618..a93a884 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -345,7 +345,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.wizard = wizardLink m.loginStage = loginAskLink m.loginMsg = "a link is on its way to " + m.loginEmail + - " — copy it out of the email without opening it, the link works once" + " — copy it out of the email without opening it, the link works once" + + " and expires " + auth.LinkValidity + " after it is sent" m.loginInput.SetValue("") m.loginInput.Placeholder = "https://nan.builders/...?token=..." m.loginInput.Prompt = "Paste the link: " @@ -2999,41 +3000,6 @@ func (m model) wrapped(l layout, msg string) string { Render(style.Render(msg)), l.indent) } -func (m model) renderLogin(l layout) string { - title := lipgloss.NewStyle().Bold(true).Foreground(cWhite) - dim := lipgloss.NewStyle().Foreground(cGray) - errStyle := lipgloss.NewStyle().Foreground(cRed) - ok := lipgloss.NewStyle().Foreground(cCyan) - - var b strings.Builder - b.WriteString(l.indent + title.Render("Sign in") + "\n\n") - - step := "Step 1 of 2 — where should the link go?" - if m.loginStage == loginAskLink { - step = "Step 2 of 2 — the link from the email" - } - b.WriteString(l.indent + dim.Render(step) + "\n\n") - - b.WriteString(l.indent + m.loginInput.View() + "\n") - - if m.loginMsg != "" { - style := ok - if strings.HasPrefix(m.loginMsg, "error") { - style = errStyle - } - // A sign-in link is longer than any terminal, so this wraps rather - // than running off the side and taking the rest of the line with it. - wrapped := lipgloss.NewStyle().Width(l.w - lipgloss.Width(l.indent) - 1). - Render(style.Render(m.loginMsg)) - b.WriteString("\n" + indentBlock(wrapped, l.indent) + "\n") - } - - if m.loginStage == loginAskLink { - b.WriteString("\n" + l.indent + dim.Render("Nothing arrived? esc, then s to start again.") + "\n") - } - return b.String() -} - // How to actually use each tool once its config is written. The panel said // "4 added" and stopped there, which answers what it did and not the question // a member is left holding: and now what. These are the steps each tool page