From 0c156213d15bc0d213b70b60eb1ef87f60126e6a Mon Sep 17 00:00:00 2001 From: borjaperfra Date: Mon, 14 Sep 2026 13:45:10 +0200 Subject: [PATCH] fix(tui): signing in left the client sending the old token Reported: signed in from the panel, and every tab still answered `unauthorized`. Which reads exactly like the login having failed, and it had not - the session was written correctly. The panel builds its API client once, in Run, from whatever token exists at start-up. On a machine that has never logged in that is none. Signing in from inside the panel reloaded the session and left that client in place, so every request went out with an empty cookie and the platform said the only thing it can say to that. The feature signed a member in and then behaved as though it had not. It builds a client for the new token now, alongside reloading the session. api.Client grows a Token() so a test can see which one it is holding. And `e` works from any tab. Home publishes it as the way to set the API key, and Home is not Setup: it did nothing at all anywhere else, which is the tab you have to already be on to know. It moves there and opens the editor. Both were seen failing before the fix went in. Co-Authored-By: Claude Opus 5 (1M context) --- internal/api/client.go | 6 ++++ internal/tui/config_test.go | 57 +++++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 26 +++++++++++++++-- scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index b007933..a5d73df 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -21,6 +21,12 @@ func New(token string) *Client { return &Client{token: token, http: &http.Client{}, baseURL: BaseURL} } +// Token is what this client sends. The panel builds a client once, at start, +// and has to build another when a member signs in from inside it - a client +// still holding the token it started with sends an empty cookie and the +// platform answers `unauthorized`, which reads exactly like a failed login. +func (c *Client) Token() string { return c.token } + func (c *Client) get(path string) ([]byte, error) { base := c.baseURL if base == "" { diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index bc46680..d8808f4 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/nxssie/nan-cli/internal/api" @@ -1170,3 +1171,59 @@ func TestSignInKeyIsNotOneThatAlreadyMoves(t *testing.T) { } } } + +// Signing in from the panel wrote the session and left the API client holding +// the token it was built with at start-up, which on a fresh machine is none. +// So it signed a member in and then answered every tab `unauthorized` - which +// reads exactly like the login having failed, and was reported as such. +func TestSigningInGivesTheClientTheNewToken(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("HERMES_HOME", filepath.Join(home, "h")) + + m := newModel(api.New(""), &session.Session{}) + if m.client.Token() != "" { + t.Fatal("this test starts from a client with no token") + } + + // What the flow leaves on disk before the panel is told about it. + if err := session.Save(&session.Session{Token: "a-fresh-session-token"}); err != nil { + t.Fatal(err) + } + + updated, _ := m.Update(signedInMsg{nil}) + after := updated.(model) + + if after.sess.Token != "a-fresh-session-token" { + t.Errorf("the panel did not pick up the session: %q", after.sess.Token) + } + if got := after.client.Token(); got != "a-fresh-session-token" { + t.Errorf("the client still sends %q, so every tab answers unauthorized", got) + } + if after.loginStage != loginOff { + t.Error("the sign-in is still on screen after it succeeded") + } + if len(after.cache) != 0 { + t.Error("the tabs keep the answers they got before signing in") + } +} + +// `e` is published on Home as the way to set the API key, and Home is not +// Setup. It did nothing at all anywhere but Setup - which is the tab you have +// to already be on to know that. +func TestTheKeyForTheKeyWorksFromWhereItIsAdvertised(t *testing.T) { + m := setupModel(t, &session.Session{Token: "t"}) + m.lay = newLayout(90, 30) + m.active = tabIndex(tabHome) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + after := updated.(model) + + if !after.editingKey { + t.Error("e from Home does nothing, which is where Home tells you to press it") + } + if after.activeID() != tabSetup { + t.Error("e opened the key editor without moving to the tab that shows it") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d562764..db9dfc0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -206,6 +206,16 @@ func (m model) Init() tea.Cmd { func (m model) activeID() tabID { return tabDefs[m.active].id } +// Where a tab sits in the bar, for the keys that jump straight to one. +func tabIndex(id tabID) int { + for i, t := range tabDefs { + if t.id == id { + return i + } + } + return 0 +} + // ── update ──────────────────────────────────────────────────────────────────── func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -256,6 +266,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if sess, err := session.Load(); err == nil { m.sess = sess } + // And a client that carries it. The old one was built at start-up with + // whatever token existed then - none - so leaving it in place signed a + // member in and then answered every tab `unauthorized`, which reads + // exactly like the login having failed. + m.client = api.New(m.sess.Token) m.cancelLogin() m.cache = make(map[tabID]any) m.err = nil @@ -420,7 +435,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "e": - if !m.showHelp && m.activeID() == tabSetup { + // From any tab. It used to do nothing at all anywhere but Setup, + // which is the tab you have to already be on to know that - and + // Home tells everyone to press `e` from Home. + if !m.showHelp && m.loginStage == loginOff { + if m.activeID() != tabSetup { + m.active = tabIndex(tabSetup) + m.scrollY = 0 + } m.editingKey = true m.keyInput.SetValue("") m.keyInput.Focus() @@ -2351,7 +2373,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.10" +const Version = "0.1.11" func renderAbout(l layout) string { var b strings.Builder diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f14940a..2d7186f 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -118,7 +118,7 @@ function Get-LatestVersion { could not work out the latest version from the GitHub API it rate limits unauthenticated requests, so this is usually temporary wait a few minutes, or pick a version yourself: - & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.10 + & ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -Version v0.1.11 the releases are at https://github.com/$Repo/releases "@ } diff --git a/scripts/install.sh b/scripts/install.sh index 5937628..461aab2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -62,7 +62,7 @@ require_version() { err "could not work out the latest version from the GitHub API" err "it rate limits unauthenticated requests, so this is usually temporary" err "wait a few minutes, or pick a version yourself:" - printf " VERSION=v0.1.10 curl -fsSL https://nan.builders/install | bash + printf " VERSION=v0.1.11 curl -fsSL https://nan.builders/install | bash " >&2 err "the releases are at https://github.com/$REPO/releases" exit 1