diff --git a/CLAUDE.md b/CLAUDE.md index 3b155ce..117607f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ Location: `~/.config/terdut-tui/config.yaml` ```yaml server_url: https://terdut.example.com -api_key: <64-char hex key> +username: niklas # optional, prefills the sign-in form refresh_interval: 30 # seconds, optional, default 30 theme: gruvbox-dark # optional, default gruvbox-dark team: Ops # optional, team name or id to start on, default all @@ -72,7 +72,14 @@ Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in. See the README for the token list. -The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`). +There is no API key in the config. The TUI signs in as a user (`POST /api/login`, the +same session cookie as the web UI) and `internal/session` keeps the token in +`~/.config/terdut-tui/session.json`, mode 0600, keyed by server URL. The client +attaches `terdut_session` itself rather than using a cookie jar, because a jar drops the +server's Secure cookie over plain http. It must never send `Authorization` as well: the +server judges a request with that header on it alone. A 401 from anything (`msgError` +in `update.go`) returns to the sign-in form and clears `Model`. A user with no password +cannot sign in, and the server answers it like a wrong one, so the form says so. ## Running @@ -105,6 +112,7 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui . | 5 | User management and API key lifecycle | | 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR | | 7 | Teams: `T` switcher, per-team schedule, admin/disabled markers (server v0.20) | +| 8 | Sign in as a user instead of an API key (server v0.10+ session cookie) | ## Memory (GrayMatter) diff --git a/README.md b/README.md index 5519657..be61d8e 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,31 @@ Create `~/.config/terdut-tui/config.yaml`: ```yaml server_url: https://terdut.example.com -api_key: +username: niklas # optional, prefills the sign-in form refresh_interval: 30 # seconds, optional theme: gruvbox-dark # optional, this is the default team: Ops # optional, a team name or id to start on; default is all ``` -The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key. +## Signing in + +The TUI signs in the way the web UI does: with a user account's username and +password, on a form shown at start. It keeps the server's session, not the +password, in `~/.config/terdut-tui/session.json` (readable by you only), so the +next start resumes it. The server's sessions last 30 days and slide with use. +When it has ended, or the account is disabled or the session is ended from the web +UI, the TUI returns to the form and says so. `L` signs out, which also ends the +session on the server and deletes the saved one. + +The account needs a password, since that is what signing in uses. A user +created only for API access has none and cannot sign in: the server answers it +exactly like a wrong password. Set one in the web UI, or have an administrator +press `p` on that user in Users. Too many failed attempts are rate limited by +the server for a few minutes. + +> **Upgrading from v0.10.0 and earlier:** `api_key` in `config.yaml` is no longer +> used. Remove it and sign in. API keys still exist on the server, and `k` in +> Users still manages them, for whatever else uses them. ## Themes @@ -152,6 +170,7 @@ Global: | `esc` | Go back | | `r` | Refresh | | `f` | Cycle filter | +| `L` | Sign out | | `T` | Switch team: all → each of your teams (when you have more than one) | | `q` | Quit | diff --git a/internal/api/client.go b/internal/api/client.go index 4b2c64e..e9a4c3e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -3,6 +3,7 @@ package api import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -11,29 +12,98 @@ import ( "time" ) +// SessionCookie is the cookie terdut-server's web UI signs in with. +const SessionCookie = "terdut_session" + +// Client talks to terdut-server as the user who signed in. Login trades a +// username and password for a session, the same one the web UI holds, and every +// request after it carries that session's cookie. +// +// The cookie is attached by hand rather than through a cookie jar: the server +// marks it Secure behind https, and a jar drops a Secure cookie it is handed +// over plain http, which would make a local server unusable for no reason. There +// is nothing else a jar would do here — the token is opaque and does not change +// while the session lives. type Client struct { baseURL string httpClient *http.Client - apiKey string + session string } -func NewClient(baseURL, apiKey string) *Client { +func NewClient(baseURL string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), - apiKey: apiKey, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } +// SetSession resumes a session from a token saved earlier. +func (c *Client) SetSession(token string) { c.session = token } + +// HasSession reports whether there is a session to try. It says nothing about +// whether the server still honours it. +func (c *Client) HasSession() bool { return c.session != "" } + +// Login signs in and returns the session token, which the client also keeps and +// sends from then on. The server answers a wrong password, an unknown user and +// an account with no password all with the same 401, so the caller cannot tell +// them apart. Too many failures come back as 429. +func (c *Client) Login(username, password string) (string, error) { + body := struct { + Username string `json:"username"` + Password string `json:"password"` + }{Username: username, Password: password} + req, err := c.newRequestWithBody(http.MethodPost, "/api/login", body) + if err != nil { + return "", err + } + // A stale session must not ride along on the request that replaces it. + req.Header.Del("Cookie") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return "", statusError(resp) + } + for _, ck := range resp.Cookies() { + if ck.Name == SessionCookie && ck.Value != "" { + c.session = ck.Value + return ck.Value, nil + } + } + return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie) +} + +// Logout ends the session on the server and forgets it here. +func (c *Client) Logout() error { + req, err := c.newRequest(http.MethodPost, "/api/logout") + if err != nil { + return err + } + err = c.do(req, nil) + c.session = "" + return err +} + +// authorize puts the session on a request. +func (c *Client) authorize(req *http.Request) { + req.Header.Set("Accept", "application/json") + if c.session != "" { + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.session}) + } +} + func (c *Client) newRequest(method, path string) (*http.Request, error) { req, err := http.NewRequest(method, c.baseURL+path, nil) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Accept", "application/json") + c.authorize(req) return req, nil } @@ -51,6 +121,21 @@ func (e *StatusError) Error() string { return fmt.Sprintf("server returned %d", e.Code) } +// IsUnauthorized reports whether err is the server refusing the session: it +// expired, was ended elsewhere, or belongs to an account since disabled. +func IsUnauthorized(err error) bool { + var se *StatusError + return errors.As(err, &se) && se.Code == http.StatusUnauthorized +} + +func statusError(resp *http.Response) error { + var e struct { + Error string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&e) + return &StatusError{Code: resp.StatusCode, Message: e.Error} +} + func (c *Client) do(req *http.Request, out any) error { resp, err := c.httpClient.Do(req) if err != nil { @@ -59,11 +144,7 @@ func (c *Client) do(req *http.Request, out any) error { defer resp.Body.Close() if resp.StatusCode >= 400 { - var e struct { - Error string `json:"error"` - } - _ = json.NewDecoder(resp.Body).Decode(&e) - return &StatusError{Code: resp.StatusCode, Message: e.Error} + return statusError(resp) } if out != nil { @@ -125,8 +206,7 @@ func (c *Client) newRequestWithBody(method, path string, body any) (*http.Reques if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Accept", "application/json") + c.authorize(req) req.Header.Set("Content-Type", "application/json") return req, nil } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index d806f24..7877006 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -19,7 +19,9 @@ type call struct { path string query string body string - auth string + cookie string + // authz is the Authorization header, which the client no longer sends at all. + authz string } // stub serves one canned response and records the request that fetched it. @@ -29,22 +31,107 @@ func stub(t *testing.T, status int, response string) (*Client, *call) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery - got.body, got.auth = string(body), r.Header.Get("Authorization") + got.body, got.authz = string(body), r.Header.Get("Authorization") + if ck, err := r.Cookie(SessionCookie); err == nil { + got.cookie = ck.Value + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) io.WriteString(w, response) })) t.Cleanup(srv.Close) - return NewClient(srv.URL, "test-key"), got + c := NewClient(srv.URL) + c.SetSession("test-session") + return c, got } -func TestClient_SendsBearerToken(t *testing.T) { +func TestClient_SendsTheSessionCookie(t *testing.T) { c, got := stub(t, http.StatusOK, `[]`) if _, err := c.ListIncidents(0, "", false, false, 0); err != nil { t.Fatalf("list: %v", err) } - if got.auth != "Bearer test-key" { - t.Errorf("expected bearer token, got %q", got.auth) + if got.cookie != "test-session" { + t.Errorf("expected the session cookie, got %q", got.cookie) + } + // The server judges a request with an Authorization header on that alone and + // never falls back to the cookie, so sending one would defeat the session. + if got.authz != "" { + t.Errorf("expected no Authorization header, got %q", got.authz) + } +} + +// Login has to work over plain http, where a cookie jar would discard the +// Secure cookie a server behind https sets. +func TestLogin_KeepsTheSessionFromTheCookie(t *testing.T) { + var body string + var sentCookie bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + _, err := r.Cookie(SessionCookie) + sentCookie = err == nil + http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "fresh", Path: "/", HttpOnly: true, Secure: true}) + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"user":{"id":1},"has_password":true}`) + })) + t.Cleanup(srv.Close) + + c := NewClient(srv.URL) + c.SetSession("stale") + token, err := c.Login("niklas", "correct horse") + if err != nil { + t.Fatalf("login: %v", err) + } + if token != "fresh" || !c.HasSession() { + t.Errorf("expected the new token to be kept, got %q", token) + } + if body != `{"username":"niklas","password":"correct horse"}` { + t.Errorf("unexpected body %q", body) + } + if sentCookie { + t.Error("a stale session must not ride along on the login that replaces it") + } +} + +func TestLogin_RefusalCarriesTheServersWords(t *testing.T) { + c, _ := stub(t, http.StatusUnauthorized, `{"error":"invalid username or password"}`) + _, err := c.Login("niklas", "wrong") + if !IsUnauthorized(err) || !strings.Contains(err.Error(), "invalid username or password") { + t.Errorf("expected the server's 401 message, got %v", err) + } + + c, _ = stub(t, http.StatusTooManyRequests, `{"error":"too many attempts"}`) + if _, err := c.Login("niklas", "wrong"); err == nil || IsUnauthorized(err) { + t.Errorf("a rate limit is not an authentication failure, got %v", err) + } +} + +func TestLogin_NoCookieIsAnError(t *testing.T) { + c, _ := stub(t, http.StatusOK, `{}`) + if _, err := c.Login("niklas", "pw"); err == nil { + t.Error("a 200 without a session cookie is not a sign-in") + } +} + +func TestLogout_ForgetsTheSession(t *testing.T) { + c, got := stub(t, http.StatusNoContent, ``) + if err := c.Logout(); err != nil { + t.Fatalf("logout: %v", err) + } + if got.method != "POST" || got.path != "/api/logout" || got.cookie != "test-session" { + t.Errorf("unexpected request %s %s cookie=%q", got.method, got.path, got.cookie) + } + if c.HasSession() { + t.Error("the session should be gone locally") + } +} + +func TestIsUnauthorized(t *testing.T) { + if !IsUnauthorized(&StatusError{Code: 401}) { + t.Error("a 401 is unauthorized") + } + if IsUnauthorized(&StatusError{Code: 403}) || IsUnauthorized(errors.New("x")) || IsUnauthorized(nil) { + t.Error("only a 401 means the session is refused; a 403 is a permission") } } diff --git a/internal/config/config.go b/internal/config/config.go index 58d22e1..2615fa0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,10 +13,14 @@ const defaultRefreshInterval = 30 * time.Second type Config struct { ServerURL string - APIKey string + Username string // optional, prefills the sign-in form RefreshInterval time.Duration Theme string + // LegacyAPIKey is set when the file still has an `api_key`. The TUI signs in + // with a user account now and ignores it; this is only so it can say so. + LegacyAPIKey bool + // Team is the team to start on, by name or id. Empty shows every team the // key's user belongs to. Team string @@ -24,7 +28,8 @@ type Config struct { type rawConfig struct { ServerURL string `yaml:"server_url"` - APIKey string `yaml:"api_key"` + Username string `yaml:"username,omitempty"` + APIKey string `yaml:"api_key,omitempty"` // no longer used; see Config.LegacyAPIKey RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds Theme string `yaml:"theme,omitempty"` Team string `yaml:"team,omitempty"` @@ -40,7 +45,7 @@ func Load() (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: \n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on", path) + return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n username: # optional, prefills the sign-in form\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on", path) } return nil, fmt.Errorf("cannot read config file: %w", err) } @@ -53,9 +58,6 @@ func Load() (*Config, error) { if raw.ServerURL == "" { return nil, fmt.Errorf("config: 'server_url' is required") } - if raw.APIKey == "" { - return nil, fmt.Errorf("config: 'api_key' is required") - } interval := defaultRefreshInterval if raw.RefreshInterval > 0 { @@ -64,7 +66,8 @@ func Load() (*Config, error) { return &Config{ ServerURL: raw.ServerURL, - APIKey: raw.APIKey, + Username: raw.Username, + LegacyAPIKey: raw.APIKey != "", RefreshInterval: interval, Theme: raw.Theme, Team: raw.Team, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index aa618e4..56cbc54 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,7 +19,7 @@ func writeConfig(t *testing.T, body string) { } func TestLoad_TeamIsOptional(t *testing.T) { - writeConfig(t, "server_url: https://terdut.example.com\napi_key: k\n") + writeConfig(t, "server_url: https://terdut.example.com\n") cfg, err := Load() if err != nil { t.Fatalf("load: %v", err) @@ -28,7 +28,7 @@ func TestLoad_TeamIsOptional(t *testing.T) { t.Errorf("expected no default team, got %q", cfg.Team) } - writeConfig(t, "server_url: https://terdut.example.com\napi_key: k\nteam: Ops\n") + writeConfig(t, "server_url: https://terdut.example.com\nteam: Ops\n") cfg, err = Load() if err != nil { t.Fatalf("load: %v", err) @@ -37,3 +37,25 @@ func TestLoad_TeamIsOptional(t *testing.T) { t.Errorf("expected team Ops, got %q", cfg.Team) } } + +// Signing in replaced the API key, so a config that has only a server URL is +// complete, and one that still carries an api_key is noted rather than refused. +func TestLoad_NoAPIKeyNeeded(t *testing.T) { + writeConfig(t, "server_url: https://terdut.example.com\nusername: niklas\n") + cfg, err := Load() + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Username != "niklas" || cfg.LegacyAPIKey { + t.Errorf("unexpected config %+v", cfg) + } + + writeConfig(t, "server_url: https://terdut.example.com\napi_key: old\n") + cfg, err = Load() + if err != nil { + t.Fatalf("a leftover api_key must not stop the TUI starting: %v", err) + } + if !cfg.LegacyAPIKey { + t.Error("expected the leftover api_key to be noted") + } +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..b3a8de6 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,98 @@ +// Package session keeps the signed-in session between runs, so the TUI does not +// ask for a password every time it starts. +// +// What is stored is the server's session token, not the password: it is what the +// web UI keeps in a cookie, it expires on the server's schedule (30 days, sliding +// with use) and signing out or a password change ends it. It is still a +// credential, so the file is readable by its owner only. +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +type file struct { + ServerURL string `json:"server_url"` + Token string `json:"token"` +} + +func path() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "terdut-tui", "session.json"), nil +} + +// normalise makes two spellings of one server compare equal. +func normalise(serverURL string) string { + return strings.TrimRight(serverURL, "/") +} + +// Load returns the saved token for serverURL, or "" when there is none. A session +// saved for a different server is not offered to this one, and an unreadable file +// is the same as no file: the worst outcome is being asked to sign in. +func Load(serverURL string) string { + p, err := path() + if err != nil { + return "" + } + data, err := os.ReadFile(p) + if err != nil { + return "" + } + var f file + if json.Unmarshal(data, &f) != nil || normalise(f.ServerURL) != normalise(serverURL) { + return "" + } + return f.Token +} + +// Save stores the token for serverURL, replacing whatever was there. +func Save(serverURL, token string) error { + p, err := path() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + data, err := json.Marshal(file{ServerURL: normalise(serverURL), Token: token}) + if err != nil { + return err + } + // Written beside the target and renamed over it, so a crash cannot leave a + // half-written token, and created 0600 so it is never briefly world-readable. + tmp, err := os.CreateTemp(filepath.Dir(p), ".session-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if err := os.Chmod(tmp.Name(), 0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), p) +} + +// Clear forgets the saved session. Having none to forget is not an error. +func Clear() error { + p, err := path() + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..df82152 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,92 @@ +package session + +import ( + "os" + "path/filepath" + "testing" +) + +func isolate(t *testing.T) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) +} + +func TestSaveThenLoad(t *testing.T) { + isolate(t) + if got := Load("https://terdut.example.com"); got != "" { + t.Fatalf("expected no session yet, got %q", got) + } + if err := Save("https://terdut.example.com", "tok"); err != nil { + t.Fatalf("save: %v", err) + } + if got := Load("https://terdut.example.com"); got != "tok" { + t.Errorf("expected tok, got %q", got) + } + // A trailing slash is the same server. + if got := Load("https://terdut.example.com/"); got != "tok" { + t.Errorf("a trailing slash should not lose the session, got %q", got) + } +} + +// A token is only ever valid for the server that issued it; offering it to a +// different one would send a credential to somebody it was never meant for. +func TestLoadIgnoresAnotherServersSession(t *testing.T) { + isolate(t) + if err := Save("https://a.example.com", "tok"); err != nil { + t.Fatal(err) + } + if got := Load("https://b.example.com"); got != "" { + t.Errorf("a session for another server must not be reused, got %q", got) + } +} + +func TestSaveIsOwnerOnly(t *testing.T) { + isolate(t) + if err := Save("https://a.example.com", "tok"); err != nil { + t.Fatal(err) + } + p, _ := path() + info, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("the session file holds a credential and must be 0600, got %o", perm) + } + entries, _ := os.ReadDir(filepath.Dir(p)) + for _, e := range entries { + if e.Name() != "session.json" { + t.Errorf("a temporary file was left behind: %s", e.Name()) + } + } +} + +func TestClear(t *testing.T) { + isolate(t) + if err := Clear(); err != nil { + t.Errorf("clearing nothing is not an error, got %v", err) + } + if err := Save("https://a.example.com", "tok"); err != nil { + t.Fatal(err) + } + if err := Clear(); err != nil { + t.Fatalf("clear: %v", err) + } + if got := Load("https://a.example.com"); got != "" { + t.Errorf("expected the session gone, got %q", got) + } +} + +func TestLoadToleratesGarbage(t *testing.T) { + isolate(t) + p, _ := path() + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if got := Load("https://a.example.com"); got != "" { + t.Errorf("an unreadable file means no session, got %q", got) + } +} diff --git a/internal/tui/login_test.go b/internal/tui/login_test.go new file mode 100644 index 0000000..1bebc69 --- /dev/null +++ b/internal/tui/login_test.go @@ -0,0 +1,242 @@ +package tui + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/session" + "git.ryuvia.com/niklas/terdut-tui/internal/theme" + tea "github.com/charmbracelet/bubbletea" +) + +// signedOut is a model with no session, so it starts on the sign-in form. +func signedOut(serverURL string) Model { + m := NewModel(api.NewClient(serverURL), serverURL, time.Minute, theme.GruvboxDark) + m.width, m.height = 120, 40 + return m +} + +func TestStartsOnTheFormWithoutASession(t *testing.T) { + m := signedOut("http://test") + if m.mode != modeLogin { + t.Fatalf("expected the sign-in form, got mode %v", m.mode) + } + if m.Init() != nil { + t.Error("with no session there is nothing to connect with yet") + } +} + +func TestStartsConnectedWithASavedSession(t *testing.T) { + c := api.NewClient("http://test") + c.SetSession("saved") + m := NewModel(c, "http://test", time.Minute, theme.GruvboxDark) + if m.mode == modeLogin { + t.Fatal("a saved session should be tried before asking for a password") + } + if m.Init() == nil { + t.Error("expected the saved session to be tried on start") + } +} + +func TestLoginForm_ChecksBothFieldsBeforeSending(t *testing.T) { + m := signedOut("http://test") + m, cmd := press(t, m, "enter") + if cmd != nil || m.loggingIn || !strings.Contains(m.loginErr, "username") { + t.Errorf("an empty form must not be sent, got err %q", m.loginErr) + } + + m = typeInto(t, m, "niklas") + m, cmd = press(t, m, "enter") + if cmd != nil || m.loggingIn || !strings.Contains(m.loginErr, "password") { + t.Errorf("a missing password must not be sent, got err %q", m.loginErr) + } +} + +func TestLoginForm_QIsTypedNotQuit(t *testing.T) { + m := signedOut("http://test") + m = typeInto(t, m, "quentin") + if got := m.loginInputs[loginUsername].Value(); got != "quentin" { + t.Errorf("q is a letter in a username, got %q", got) + } +} + +// The whole flow against a server: type both fields, submit, and the session is +// kept for the next run. +func TestLogin_SignsInAndSavesTheSession(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if r.URL.Path != "/api/login" || string(b) != `{"username":"niklas","password":"secret-pass"}` { + t.Errorf("unexpected request %s %s", r.URL.Path, b) + } + http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-1", Path: "/"}) + io.WriteString(w, `{}`) + })) + t.Cleanup(srv.Close) + + m := signedOut(srv.URL) + m = typeInto(t, m, "niklas") + m, _ = press(t, m, "tab") + m = typeInto(t, m, "secret-pass") + m, cmd := press(t, m, "enter") + if !m.loggingIn || cmd == nil { + t.Fatal("expected the sign-in to be under way") + } + + msg := cmd() + if _, ok := msg.(loginDoneMsg); !ok { + t.Fatalf("expected loginDoneMsg, got %#v", msg) + } + if got := session.Load(srv.URL); got != "tok-1" { + t.Errorf("expected the session saved for next time, got %q", got) + } + + next, connect := m.Update(msg) + m = next.(Model) + if m.mode != modeDashboard || m.loggingIn || connect == nil { + t.Errorf("expected to move on and connect, got mode %v", m.mode) + } + if m.loginInputs[loginPassword].Value() != "" { + t.Error("the password must not be kept once it has been used") + } +} + +func TestLogin_WrongPasswordStaysOnTheForm(t *testing.T) { + m := signedOut("http://test") + m.loggingIn = true + next, _ := m.Update(loginErrMsg{&api.StatusError{Code: 401, Message: "invalid username or password"}}) + m = next.(Model) + if m.mode != modeLogin || m.loggingIn { + t.Fatalf("expected to be back on the form, got mode %v", m.mode) + } + // The server gives the same 401 for an account with no password, so the + // message has to say so or it reads as a typo. + if !strings.Contains(m.loginErr, "no password") { + t.Errorf("expected the no-password hint, got %q", m.loginErr) + } + if m.loginFocus != loginPassword { + t.Error("focus should return to the password to retry") + } + + next, _ = m.Update(loginErrMsg{&api.StatusError{Code: 429, Message: "slow down"}}) + if got := next.(Model).loginErr; !strings.Contains(got, "too many") { + t.Errorf("expected the rate limit explained, got %q", got) + } + next, _ = m.Update(loginErrMsg{errors.New("dial tcp: refused")}) + if got := next.(Model).loginErr; !strings.Contains(got, "refused") { + t.Errorf("other errors should show as they are, got %q", got) + } +} + +// A 401 anywhere means the session is gone. Every action would fail the same +// way, so it goes back to the form instead, without keeping the old data. +func TestUnauthorized_ReturnsToTheFormAndDropsTheData(t *testing.T) { + for name, msg := range map[string]tea.Msg{ + "refresh": fetchDataErrMsg{&api.StatusError{Code: 401}}, + "action": actionErrMsg{&api.StatusError{Code: 401}}, + "schedule": scheduleActionErrMsg{&api.StatusError{Code: 401}}, + "connect": connectErrMsg{&api.StatusError{Code: 401}}, + } { + t.Run(name, func(t *testing.T) { + m := sized() + m.incidents = []api.Incident{{ID: 1, Title: "secret incident"}} + m.teams = twoTeams() + m.isAdmin = true + m.loginInputs[loginUsername].SetValue("niklas") + + next, cmd := m.Update(msg) + m = next.(Model) + if m.mode != modeLogin || m.connected { + t.Fatalf("expected the sign-in form, got mode %v connected=%v", m.mode, m.connected) + } + if len(m.incidents) != 0 || len(m.teams) != 0 || m.isAdmin { + t.Error("the previous session's data must not survive into the next sign-in") + } + if cmd == nil { + t.Error("the dead session should be forgotten on disk") + } + if !strings.Contains(m.loginNote, "session") { + t.Errorf("expected the reason, got %q", m.loginNote) + } + if m.loginInputs[loginUsername].Value() != "niklas" || m.loginFocus != loginPassword { + t.Error("the username should be kept so only the password is retyped") + } + if strings.Contains(m.View(), "secret incident") { + t.Error("nothing from the old session may still be on screen") + } + }) + } +} + +// A 403 is a permission, not a lost session: it must not sign anybody out. +func TestForbidden_DoesNotSignOut(t *testing.T) { + m := sized() + next, _ := m.Update(actionErrMsg{&api.StatusError{Code: 403, Message: "administrator access required"}}) + if got := next.(Model); got.mode == modeLogin || !got.connected { + t.Error("a 403 must leave the session alone") + } +} + +func TestLogout(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + m := sized() + m.teams = twoTeams() + m.incidents = []api.Incident{{ID: 1}} + m, cmd := press(t, m, "L") + if cmd == nil { + t.Fatal("L should sign out") + } + next, _ := m.Update(logoutDoneMsg{}) + m = next.(Model) + if m.mode != modeLogin || m.connected || len(m.incidents) != 0 { + t.Errorf("expected the form with nothing loaded, got mode %v", m.mode) + } + if !strings.Contains(m.loginNote, "signed out") { + t.Errorf("expected it to say so, got %q", m.loginNote) + } +} + +// Signing out and in again must not leave two refresh timers running, each +// re-arming itself for ever. +func TestSigningInAgainStartsNoSecondTimer(t *testing.T) { + m := sized() + next, _ := m.Update(connectedMsg{}) + m = next.(Model) + if !m.ticking { + t.Fatal("the first connect starts the refresh timer") + } + m = m.requireLogin("x") + if !m.ticking { + t.Fatal("the timer is still running while signed out") + } + // Ticks while signed out must do nothing rather than fetch. + if _, cmd := m.Update(tickMsg(time.Now())); cmd == nil { + t.Error("the timer keeps ticking") + } + if cmd := m.refreshActiveSection(); cmd != nil { + t.Error("no refresh should be attempted while signed out") + } +} + +func TestLoginView_HidesThePasswordAndShowsTheNote(t *testing.T) { + m := signedOut("https://terdut.example.com").WithLogin("niklas", "api_key in config.yaml is no longer used") + if m.loginFocus != loginPassword { + t.Error("with the username known the cursor should start on the password") + } + m = typeInto(t, m, "hunter2-hunter2") + view := m.View() + for _, want := range []string{"Sign in to https://terdut.example.com", "niklas", "api_key in config.yaml is no longer used"} { + if !strings.Contains(view, want) { + t.Errorf("expected %q on the form:\n%s", want, view) + } + } + if strings.Contains(view, "hunter2") { + t.Error("the password must be masked") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 2674636..fd8523c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,6 +10,7 @@ import ( "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/session" "git.ryuvia.com/niklas/terdut-tui/internal/theme" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" @@ -52,6 +53,14 @@ const ( modeAPIKeyReveal modeAPIKeyRevokeByID modePasswordSet + modeLogin +) + +// Fields of the sign-in form, in tab order. +const ( + loginUsername = iota + loginPassword + loginFieldCount ) // Fields of the set-password form, in tab order. @@ -115,6 +124,9 @@ type connectedMsg struct { me api.Me } type connectErrMsg struct{ err error } +type loginDoneMsg struct{} +type loginErrMsg struct{ err error } +type logoutDoneMsg struct{} type incidentsFetchedMsg struct{ incidents []api.Incident } type archivedIncidentsFetchedMsg struct{ incidents []api.Incident } type incidentActionDoneMsg struct { @@ -188,6 +200,7 @@ type Model struct { client *api.Client serverURL string refreshInterval time.Duration + theme theme.Theme // kept to build a fresh model when signing out activeSection section mode mode @@ -203,6 +216,17 @@ type Model struct { meID int64 isAdmin bool + // Sign-in. Until the server accepts a session the TUI is in modeLogin; + // loginNote is a line the form shows above the fields (why we are here), and + // ticking says the refresh timer is already running, so signing in again + // after signing out does not start a second one. + loginInputs [loginFieldCount]textinput.Model + loginFocus int + loggingIn bool + loginErr string + loginNote string + ticking bool + // Connection & dashboard connected bool loading bool @@ -365,6 +389,18 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer } + var loginIn [loginFieldCount]textinput.Model + for i, placeholder := range [loginFieldCount]string{"username", "password"} { + loginIn[i] = textinput.New() + loginIn[i].Placeholder = placeholder + loginIn[i].CharLimit = 72 // bcrypt's limit, and the server refuses longer passwords + } + loginIn[loginPassword].EchoMode = textinput.EchoPassword + loginIn[loginPassword].EchoCharacter = '•' + for i := range loginIn { + loginIn[i] = st.Input(loginIn[i]) + } + for _, in := range []*textinput.Model{ ¬eIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, } { @@ -385,12 +421,20 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio } window := today.AddDate(0, 0, -(weekday - 1)) + startMode := modeDashboard + if client != nil && !client.HasSession() { + startMode = modeLogin + loginIn[loginUsername].Focus() + } + return Model{ client: client, serverURL: serverURL, refreshInterval: refreshInterval, + theme: th, activeSection: sectionIncidents, - mode: modeDashboard, + mode: startMode, + loginInputs: loginIn, loading: true, incidentFilter: "", alertFilter: "firing", @@ -424,7 +468,25 @@ func (m Model) WithDefaultTeam(team string) Model { return m } +// WithLogin prefills the sign-in form's username and sets a note shown above it. +func (m Model) WithLogin(username, note string) Model { + m.loginInputs[loginUsername].SetValue(username) + m.loginNote = note + if username != "" && m.mode == modeLogin { + // The name is known, so the only thing left to type is the password. + m.loginInputs[loginUsername].Blur() + m.loginFocus = loginPassword + m.loginInputs[loginPassword].Focus() + } + return m +} + +// Init tries the saved session, if there is one; otherwise the sign-in form is +// already showing and there is nothing to do until it is submitted. func (m Model) Init() tea.Cmd { + if m.mode == modeLogin { + return nil + } return connectCmd(m.client) } @@ -975,6 +1037,38 @@ func connectCmd(client *api.Client) tea.Cmd { } } +// loginCmd signs in and saves the session, so the next run can resume it. Failing +// to save is not failing to sign in: the session works for this run either way. +func loginCmd(client *api.Client, serverURL, username, password string) tea.Cmd { + return func() tea.Msg { + token, err := client.Login(username, password) + if err != nil { + return loginErrMsg{err} + } + _ = session.Save(serverURL, token) + return loginDoneMsg{} + } +} + +// logoutCmd ends the session on the server and deletes the saved one. The saved +// copy goes even when the server cannot be reached, because the person asked to +// be signed out and a token left on disk would say otherwise. +func logoutCmd(client *api.Client) tea.Cmd { + return func() tea.Msg { + _ = client.Logout() + _ = session.Clear() + return logoutDoneMsg{} + } +} + +// forgetSessionCmd drops a saved session the server no longer honours. +func forgetSessionCmd() tea.Cmd { + return func() tea.Msg { + _ = session.Clear() + return nil + } +} + func fetchIncidentsCmd(client *api.Client, teamID int64, filter string) tea.Cmd { return func() tea.Msg { status, snoozed := incidentQuery(filter) diff --git a/internal/tui/update.go b/internal/tui/update.go index 59b9c4d..ea22f0f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,7 +1,9 @@ package tui import ( + "errors" "fmt" + "net/http" "slices" "strconv" "strings" @@ -13,6 +15,14 @@ import ( ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // A 401 from anything means the server no longer honours the session: it + // expired, was ended from the web UI, or the account was disabled. Whatever + // was being done cannot succeed, so go back to the sign-in form and say why, + // rather than leaving every action to fail with "server returned 401". + if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin { + return m.requireLogin("your session has ended — sign in again"), forgetSessionCmd() + } + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -33,6 +43,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // ── Dashboard messages ──────────────────────────────────────────────── + case loginDoneMsg: + m.loggingIn = false + m.loginErr = "" + m.loginNote = "" + m.loginInputs[loginPassword].Reset() + m.blurLoginForm() + m.mode = modeDashboard + m.err = nil + return m, connectCmd(m.client) + + case loginErrMsg: + m.loggingIn = false + m.loginErr = loginErrorText(msg.err) + m.loginInputs[loginPassword].Reset() + m.focusLogin(loginPassword) + return m, nil + + case logoutDoneMsg: + return m.requireLogin("you have signed out"), nil + case connectedMsg: firstConnect := len(m.teams) == 0 m.connected = true @@ -52,8 +82,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.rebuildIncidentTable() m.rebuildTable() m.rebuildArchivedTable() + var tick tea.Cmd + if !m.ticking { + m.ticking = true + tick = tickCmd(m.refreshInterval) + } return m, tea.Batch( - tickCmd(m.refreshInterval), + tick, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter), fetchStatsCmd(m.client), statusCmd, @@ -338,6 +373,14 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { case modeAPIKeyMenu, modeAPIKeyReveal: return m.handleKey(msg) + case modeLogin: + var inputCmd tea.Cmd + if !m.loggingIn { + m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg) + } + m2, ourCmd := m.handleKey(msg) + return m2, tea.Batch(inputCmd, ourCmd) + case modePasswordSet: var inputCmd tea.Cmd if !m.pwLoading { @@ -407,6 +450,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m.handleAPIKeyMenuKey(msg) case modePasswordSet: return m.handlePasswordKey(msg) + case modeLogin: + return m.handleLoginKey(msg) case modeAPIKeyCreate: return m.handleAPIKeyCreateKey(msg) case modeAPIKeyReveal: @@ -456,6 +501,13 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { } return m, nil + case "L": + if !m.connected { + return m, nil + } + m.statusMsg = "Signing out…" + return m, logoutCmd(m.client) + case "T": if !m.connected || len(m.teams) == 0 { return m, nil @@ -1397,3 +1449,119 @@ func (m Model) handlePasswordKey(msg tea.KeyMsg) (Model, tea.Cmd) { } return m, nil } + +// ── Sign in ─────────────────────────────────────────────────────────────────── + +// msgError digs the error out of the messages that carry one, so the 401 check +// in Update covers them all in one place. +func msgError(msg tea.Msg) error { + switch msg := msg.(type) { + case connectErrMsg: + return msg.err + case fetchDataErrMsg: + return msg.err + case detailErrMsg: + return msg.err + case actionErrMsg: + return msg.err + case detailStatsErrMsg: + return msg.err + case scheduleFetchErrMsg: + return msg.err + case scheduleActionErrMsg: + return msg.err + case userActionErrMsg: + return msg.err + } + return nil +} + +// requireLogin returns to the sign-in form with everything the previous session +// loaded dropped, so a different account never sees the last one's incidents. +func (m Model) requireLogin(note string) Model { + name := m.loginInputs[loginUsername].Value() + fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme) + fresh.width, fresh.height = m.width, m.height + fresh.defaultTeam = m.defaultTeam + fresh.ticking = m.ticking + fresh.mode = modeLogin + fresh.loginInputs[loginUsername].SetValue(name) + fresh.loginNote = note + fresh.focusLogin(loginUsername) + if name != "" { + fresh.focusLogin(loginPassword) + } + fresh.rebuildIncidentTable() + fresh.rebuildTable() + fresh.rebuildArchivedTable() + fresh.rebuildScheduleTable() + fresh.rebuildUserPickerTable() + fresh.rebuildUserManageTable() + return fresh +} + +func (m *Model) blurLoginForm() { + for i := range m.loginInputs { + m.loginInputs[i].Blur() + } +} + +func (m *Model) focusLogin(field int) { + m.blurLoginForm() + m.loginFocus = field + m.loginInputs[field].Focus() +} + +// loginErrorText turns a failed sign-in into something to act on. The server +// answers a wrong password, an unknown user and an account with no password with +// the same 401, so the last one has to be named here or it reads as a typo. +func loginErrorText(err error) string { + var se *api.StatusError + if errors.As(err, &se) { + switch se.Code { + case http.StatusUnauthorized: + return "invalid username or password — an account with no password cannot sign in; set one in the web UI first" + case http.StatusTooManyRequests: + return "too many attempts — wait a few minutes and try again" + } + } + return err.Error() +} + +func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + } + if m.loggingIn { + return m, nil + } + + switch msg.String() { + case "tab", "shift+tab", "down", "up": + next := loginPassword + if m.loginFocus == loginPassword { + next = loginUsername + } + m.focusLogin(next) + return m, nil + + case "enter": + username := strings.TrimSpace(m.loginInputs[loginUsername].Value()) + password := m.loginInputs[loginPassword].Value() + switch { + case username == "": + m.loginErr = "enter your username" + m.focusLogin(loginUsername) + return m, nil + case password == "": + m.loginErr = "enter your password" + m.focusLogin(loginPassword) + return m, nil + } + m.loggingIn = true + m.loginErr = "" + return m, loginCmd(m.client, m.serverURL, username, password) + } + return m, nil +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 5337944..069e349 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -34,6 +34,25 @@ func (m Model) renderHeader() string { return spread(title, right, m.width) } +// renderLogin is the sign-in form. It is the whole body: nothing else is shown +// until the server has accepted a session. +func (m Model) renderLogin() string { + var b strings.Builder + b.WriteString("\n " + m.styles.Bold.Render("Sign in to "+m.serverURL) + "\n\n") + if m.loginNote != "" { + b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n") + } + b.WriteString(" " + m.styles.Header.Render("Username: ") + m.loginInputs[loginUsername].View() + "\n") + b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\n") + switch { + case m.loggingIn: + b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n") + case m.loginErr != "": + b.WriteString(m.styles.Error.Render(" "+m.loginErr) + "\n") + } + return b.String() +} + // activeTeamLabel names what the lists are narrowed to. func (m Model) activeTeamLabel() string { if t, ok := m.activeTeam(); ok { @@ -56,6 +75,9 @@ func (m Model) renderTabs() string { } func (m Model) renderBody() string { + if m.mode == modeLogin { + return m.renderLogin() + } if m.err != nil { return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) + "\n" + m.styles.Muted.Render(" Press r to retry.") @@ -160,10 +182,13 @@ func (m Model) renderFooter() string { case modePasswordSet: return withStatus(" tab·next field enter·set password esc·cancel") + case modeLogin: + return "\n" + m.styles.Footer.Render(" tab·next field enter·sign in esc·quit") + default: switch m.activeSection { case sectionIncidents: - return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section q·quit") + return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section L·sign out q·quit") case sectionAlerts: return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit") case sectionStats: @@ -173,7 +198,7 @@ func (m Model) renderFooter() string { case sectionSchedule: return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit") case sectionUsers: - return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh tab·section q·quit") + return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh L·sign out q·quit") } return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp())) } diff --git a/main.go b/main.go index 62b83f0..b14d360 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/config" + "git.ryuvia.com/niklas/terdut-tui/internal/session" "git.ryuvia.com/niklas/terdut-tui/internal/theme" "git.ryuvia.com/niklas/terdut-tui/internal/tui" "git.ryuvia.com/niklas/terdut-tui/internal/updater" @@ -45,8 +46,17 @@ func main() { os.Exit(1) } - client := api.NewClient(cfg.ServerURL, cfg.APIKey) - model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).WithDefaultTeam(cfg.Team) + client := api.NewClient(cfg.ServerURL) + if token := session.Load(cfg.ServerURL); token != "" { + client.SetSession(token) + } + note := "" + if cfg.LegacyAPIKey { + note = "api_key in config.yaml is no longer used: sign in with your username and password" + } + model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th). + WithDefaultTeam(cfg.Team). + WithLogin(cfg.Username, note) p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil {