From b203b0db00ac88f8c25ff6ae75bdacf4e38500d3 Mon Sep 17 00:00:00 2001 From: alexeira Date: Wed, 16 Sep 2026 11:30:35 -0300 Subject: [PATCH 1/2] refactor(tui): quitar la pantalla de login que ya no se dibuja MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderLogin` se quedó sin llamadas cuando el setup pasó a ser cuatro pasos bajo el banner (#19): donde antes se dibujaba aquella pantalla, `View` dibuja ahora `renderWizard`. Con el wizard puesto la vieja no se alcanza, y las dos entradas del login - `startLogin` y `linkSentMsg` - lo ponen, así que en el flujo normal no hay estado en el que se vea; la única excepción es una respuesta que llegue después de un esc, que deja `loginStage` puesto con el wizard apagado y no dibuja ninguna pantalla. Ese estado ya estaba antes de este cambio y esto no lo toca. Su test seguía pasando porque llamaba al renderer a mano, que es la única forma de que un test esté verde y equivocado a la vez, y por eso afirmaba sobre "Step 1 of 2" y "Nothing arrived? esc, then s to start again", dos textos que hoy no ve nadie: el panel enseña el wizard y el pie dice "enter to continue esc to leave setup". El test pasa a preguntarle a `View`, y a mover el flujo con el mismo mensaje que lo mueve de verdad, `linkSentMsg`. Romper el título del paso 2, el prompt `Paste the link:`, la transición de estado o el número de minutos lo hacen fallar ahora; comprobado mutando cada uno por separado. --- internal/tui/config_test.go | 30 +++++++++++++++++++++--------- internal/tui/tui.go | 35 ----------------------------------- 2 files changed, 21 insertions(+), 44 deletions(-) diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index ab28f14..89dea30 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"} { + 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..4c72a41 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -2999,41 +2999,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 From c2ebeac5ac279135c6534ea294bbc8bd66cfe7d4 Mon Sep 17 00:00:00 2001 From: alexeira Date: Wed, 16 Sep 2026 11:30:35 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(auth):=20decir=20que=20el=20enlace=20ve?= =?UTF-8?q?nce,=20tambi=C3=A9n=20cuando=20ya=20venci=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un miembro reportó que el correo con el enlace tardó más de cinco minutos. El CLI no tenía nada que decir sobre eso: decía "the link works once" y se quedaba esperando el pegado, sin mencionar que el enlace vence a los 15 minutos - la pantalla de login de la plataforma lo dice, "It expires in 15 minutes and can only be used once" - así que un correo que llega tarde llega muerto, y quien lo espera no tiene cómo saberlo. Y cuando vencía, el motivo se aplanaba: `invalid_link` cubre a la vez un enlace vencido y uno ya gastado, y en pantalla salía "the link did not work: invalid link", que no dice ni qué pasó ni qué hacer. Medido contra la plataforma, un token que la API rechaza responde 302 a /access-denied?reason=invalid_link. Ahora los dos motivos que tienen respuesta la traen - pedir otro enlace, copiar el enlace entero - y un motivo que este binario no conozca se sigue diciendo. El número vive en un solo sitio, `auth.LinkValidity`, porque lo dicen las dos mitades del flujo: `nan auth login` y el panel. --- cmd/auth.go | 2 ++ internal/auth/auth.go | 29 ++++++++++++++++++++++++++++- internal/auth/auth_test.go | 24 ++++++++++++++++++++++++ internal/tui/config_test.go | 2 +- internal/tui/tui.go | 3 ++- 5 files changed, 57 insertions(+), 3 deletions(-) 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 89dea30..3285d8d 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -1451,7 +1451,7 @@ func TestSigningInHappensInsideThePanel(t *testing.T) { 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"} { + 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 4c72a41..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: "