Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: ")
Expand Down
29 changes: 28 additions & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package auth
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
Expand All @@ -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
Expand Down Expand Up @@ -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, "_", " ")
}
24 changes: 24 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
30 changes: 21 additions & 9 deletions internal/tui/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})

Expand All @@ -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)
}
}
}

Expand Down
38 changes: 2 additions & 36 deletions internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "
Expand Down Expand Up @@ -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
Expand Down