Sign in as a user instead of with an API key
CI / test (push) Successful in 12s
Release / test (push) Successful in 5s
Release / binaries (push) Successful in 12s

The web UI signs in with a username and password and holds a session
cookie; the TUI was the only client still needing an API key pasted into
a config file. It now asks for the same credentials on a form at start.

What is kept between runs is the session token, not the password, in
session.json under the config directory, mode 0600 and keyed by server
URL so one server's token is never offered to another. It resumes on the
next start; the server's sessions last 30 days and slide with use. L
signs out, which ends the session on the server and deletes the saved
one even if the server cannot be reached.

The client attaches the cookie by hand instead of using a cookie jar:
the server marks it Secure behind https, and a jar drops a Secure cookie
it is given over plain http, which would break a local server for no
reason. It sends no Authorization header at all, since the server judges
a request carrying one on that alone and never falls back to the cookie.
Writes go through the server's cross-origin guard, which lets a client
that sends neither Origin nor Sec-Fetch-Site through; checked against a
real v0.20.1 server for both reads and writes.

A 401 from anything means the session is gone (expired, ended from the
web UI, or the account disabled), so the TUI returns to the form with the
reason, forgets the saved token, and drops what the last session loaded
rather than showing it to whoever signs in next. A 403 is a permission
and leaves the session alone. The refresh timer is started once, so
signing out and in does not leave two running.

An account with no password cannot sign in, and the server answers it
exactly like a wrong password, so the form's message says a password
must be set first. Users created only for API access hit this.

Breaking: api_key in config.yaml is no longer used. It is not an error
to leave it there; the form says it is ignored. API keys still exist on
the server and k in Users still manages them.
This commit is contained in:
Niklas Ye
2026-09-23 22:15:14 +02:00
parent 496e7b6d90
commit f4ca0059dc
13 changed files with 985 additions and 37 deletions
+10 -2
View File
@@ -62,7 +62,7 @@ Location: `~/.config/terdut-tui/config.yaml`
```yaml ```yaml
server_url: https://terdut.example.com 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 refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark theme: gruvbox-dark # optional, default gruvbox-dark
team: Ops # optional, team name or id to start on, default all 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. files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in.
See the README for the token list. 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 ## Running
@@ -105,6 +112,7 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui .
| 5 | User management and API key lifecycle | | 5 | User management and API key lifecycle |
| 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR | | 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR |
| 7 | Teams: `T` switcher, per-team schedule, admin/disabled markers (server v0.20) | | 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) |
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten --> <!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
## Memory (GrayMatter) ## Memory (GrayMatter)
+21 -2
View File
@@ -85,13 +85,31 @@ Create `~/.config/terdut-tui/config.yaml`:
```yaml ```yaml
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <your-api-key> username: niklas # optional, prefills the sign-in form
refresh_interval: 30 # seconds, optional refresh_interval: 30 # seconds, optional
theme: gruvbox-dark # optional, this is the default theme: gruvbox-dark # optional, this is the default
team: Ops # optional, a team name or id to start on; default is all 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 ## Themes
@@ -152,6 +170,7 @@ Global:
| `esc` | Go back | | `esc` | Go back |
| `r` | Refresh | | `r` | Refresh |
| `f` | Cycle filter | | `f` | Cycle filter |
| `L` | Sign out |
| `T` | Switch team: all → each of your teams (when you have more than one) | | `T` | Switch team: all → each of your teams (when you have more than one) |
| `q` | Quit | | `q` | Quit |
+92 -12
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@@ -11,29 +12,98 @@ import (
"time" "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 { type Client struct {
baseURL string baseURL string
httpClient *http.Client httpClient *http.Client
apiKey string session string
} }
func NewClient(baseURL, apiKey string) *Client { func NewClient(baseURL string) *Client {
return &Client{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, 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) { func (c *Client) newRequest(method, path string) (*http.Request, error) {
req, err := http.NewRequest(method, c.baseURL+path, nil) req, err := http.NewRequest(method, c.baseURL+path, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) c.authorize(req)
req.Header.Set("Accept", "application/json")
return req, nil return req, nil
} }
@@ -51,6 +121,21 @@ func (e *StatusError) Error() string {
return fmt.Sprintf("server returned %d", e.Code) 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 { func (c *Client) do(req *http.Request, out any) error {
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -59,11 +144,7 @@ func (c *Client) do(req *http.Request, out any) error {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
var e struct { return statusError(resp)
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
return &StatusError{Code: resp.StatusCode, Message: e.Error}
} }
if out != nil { if out != nil {
@@ -125,8 +206,7 @@ func (c *Client) newRequestWithBody(method, path string, body any) (*http.Reques
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) c.authorize(req)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
return req, nil return req, nil
} }
+93 -6
View File
@@ -19,7 +19,9 @@ type call struct {
path string path string
query string query string
body 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. // 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) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
io.WriteString(w, response) io.WriteString(w, response)
})) }))
t.Cleanup(srv.Close) 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, `[]`) c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(0, "", false, false, 0); err != nil { if _, err := c.ListIncidents(0, "", false, false, 0); err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
if got.auth != "Bearer test-key" { if got.cookie != "test-session" {
t.Errorf("expected bearer token, got %q", got.auth) 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")
} }
} }
+10 -7
View File
@@ -13,10 +13,14 @@ const defaultRefreshInterval = 30 * time.Second
type Config struct { type Config struct {
ServerURL string ServerURL string
APIKey string Username string // optional, prefills the sign-in form
RefreshInterval time.Duration RefreshInterval time.Duration
Theme string 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 // Team is the team to start on, by name or id. Empty shows every team the
// key's user belongs to. // key's user belongs to.
Team string Team string
@@ -24,7 +28,8 @@ type Config struct {
type rawConfig struct { type rawConfig struct {
ServerURL string `yaml:"server_url"` 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 RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"` Theme string `yaml:"theme,omitempty"`
Team string `yaml:"team,omitempty"` Team string `yaml:"team,omitempty"`
@@ -40,7 +45,7 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { 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: <your-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: <your-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) return nil, fmt.Errorf("cannot read config file: %w", err)
} }
@@ -53,9 +58,6 @@ func Load() (*Config, error) {
if raw.ServerURL == "" { if raw.ServerURL == "" {
return nil, fmt.Errorf("config: 'server_url' is required") return nil, fmt.Errorf("config: 'server_url' is required")
} }
if raw.APIKey == "" {
return nil, fmt.Errorf("config: 'api_key' is required")
}
interval := defaultRefreshInterval interval := defaultRefreshInterval
if raw.RefreshInterval > 0 { if raw.RefreshInterval > 0 {
@@ -64,7 +66,8 @@ func Load() (*Config, error) {
return &Config{ return &Config{
ServerURL: raw.ServerURL, ServerURL: raw.ServerURL,
APIKey: raw.APIKey, Username: raw.Username,
LegacyAPIKey: raw.APIKey != "",
RefreshInterval: interval, RefreshInterval: interval,
Theme: raw.Theme, Theme: raw.Theme,
Team: raw.Team, Team: raw.Team,
+24 -2
View File
@@ -19,7 +19,7 @@ func writeConfig(t *testing.T, body string) {
} }
func TestLoad_TeamIsOptional(t *testing.T) { 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() cfg, err := Load()
if err != nil { if err != nil {
t.Fatalf("load: %v", err) 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) 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() cfg, err = Load()
if err != nil { if err != nil {
t.Fatalf("load: %v", err) t.Fatalf("load: %v", err)
@@ -37,3 +37,25 @@ func TestLoad_TeamIsOptional(t *testing.T) {
t.Errorf("expected team Ops, got %q", cfg.Team) 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")
}
}
+98
View File
@@ -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
}
+92
View File
@@ -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)
}
}
+242
View File
@@ -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")
}
}
+95 -1
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
"git.ryuvia.com/niklas/terdut-tui/internal/theme" "git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@@ -52,6 +53,14 @@ const (
modeAPIKeyReveal modeAPIKeyReveal
modeAPIKeyRevokeByID modeAPIKeyRevokeByID
modePasswordSet 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. // Fields of the set-password form, in tab order.
@@ -115,6 +124,9 @@ type connectedMsg struct {
me api.Me me api.Me
} }
type connectErrMsg struct{ err error } type connectErrMsg struct{ err error }
type loginDoneMsg struct{}
type loginErrMsg struct{ err error }
type logoutDoneMsg struct{}
type incidentsFetchedMsg struct{ incidents []api.Incident } type incidentsFetchedMsg struct{ incidents []api.Incident }
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident } type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
type incidentActionDoneMsg struct { type incidentActionDoneMsg struct {
@@ -188,6 +200,7 @@ type Model struct {
client *api.Client client *api.Client
serverURL string serverURL string
refreshInterval time.Duration refreshInterval time.Duration
theme theme.Theme // kept to build a fresh model when signing out
activeSection section activeSection section
mode mode mode mode
@@ -203,6 +216,17 @@ type Model struct {
meID int64 meID int64
isAdmin bool 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 // Connection & dashboard
connected bool connected bool
loading 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 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{ for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, &noteIn, &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)) window := today.AddDate(0, 0, -(weekday - 1))
startMode := modeDashboard
if client != nil && !client.HasSession() {
startMode = modeLogin
loginIn[loginUsername].Focus()
}
return Model{ return Model{
client: client, client: client,
serverURL: serverURL, serverURL: serverURL,
refreshInterval: refreshInterval, refreshInterval: refreshInterval,
theme: th,
activeSection: sectionIncidents, activeSection: sectionIncidents,
mode: modeDashboard, mode: startMode,
loginInputs: loginIn,
loading: true, loading: true,
incidentFilter: "", incidentFilter: "",
alertFilter: "firing", alertFilter: "firing",
@@ -424,7 +468,25 @@ func (m Model) WithDefaultTeam(team string) Model {
return m 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 { func (m Model) Init() tea.Cmd {
if m.mode == modeLogin {
return nil
}
return connectCmd(m.client) 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 { func fetchIncidentsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
status, snoozed := incidentQuery(filter) status, snoozed := incidentQuery(filter)
+169 -1
View File
@@ -1,7 +1,9 @@
package tui package tui
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -13,6 +15,14 @@ import (
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
@@ -33,6 +43,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Dashboard messages ──────────────────────────────────────────────── // ── 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: case connectedMsg:
firstConnect := len(m.teams) == 0 firstConnect := len(m.teams) == 0
m.connected = true m.connected = true
@@ -52,8 +82,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildIncidentTable() m.rebuildIncidentTable()
m.rebuildTable() m.rebuildTable()
m.rebuildArchivedTable() m.rebuildArchivedTable()
var tick tea.Cmd
if !m.ticking {
m.ticking = true
tick = tickCmd(m.refreshInterval)
}
return m, tea.Batch( return m, tea.Batch(
tickCmd(m.refreshInterval), tick,
fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter), fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter),
fetchStatsCmd(m.client), fetchStatsCmd(m.client),
statusCmd, statusCmd,
@@ -338,6 +373,14 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case modeAPIKeyMenu, modeAPIKeyReveal: case modeAPIKeyMenu, modeAPIKeyReveal:
return m.handleKey(msg) 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: case modePasswordSet:
var inputCmd tea.Cmd var inputCmd tea.Cmd
if !m.pwLoading { if !m.pwLoading {
@@ -407,6 +450,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleAPIKeyMenuKey(msg) return m.handleAPIKeyMenuKey(msg)
case modePasswordSet: case modePasswordSet:
return m.handlePasswordKey(msg) return m.handlePasswordKey(msg)
case modeLogin:
return m.handleLoginKey(msg)
case modeAPIKeyCreate: case modeAPIKeyCreate:
return m.handleAPIKeyCreateKey(msg) return m.handleAPIKeyCreateKey(msg)
case modeAPIKeyReveal: case modeAPIKeyReveal:
@@ -456,6 +501,13 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
return m, nil return m, nil
case "L":
if !m.connected {
return m, nil
}
m.statusMsg = "Signing out…"
return m, logoutCmd(m.client)
case "T": case "T":
if !m.connected || len(m.teams) == 0 { if !m.connected || len(m.teams) == 0 {
return m, nil return m, nil
@@ -1397,3 +1449,119 @@ func (m Model) handlePasswordKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
return m, nil 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
}
+27 -2
View File
@@ -34,6 +34,25 @@ func (m Model) renderHeader() string {
return spread(title, right, m.width) 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. // activeTeamLabel names what the lists are narrowed to.
func (m Model) activeTeamLabel() string { func (m Model) activeTeamLabel() string {
if t, ok := m.activeTeam(); ok { if t, ok := m.activeTeam(); ok {
@@ -56,6 +75,9 @@ func (m Model) renderTabs() string {
} }
func (m Model) renderBody() string { func (m Model) renderBody() string {
if m.mode == modeLogin {
return m.renderLogin()
}
if m.err != nil { if m.err != nil {
return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) + return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + m.styles.Muted.Render(" Press r to retry.") "\n" + m.styles.Muted.Render(" Press r to retry.")
@@ -160,10 +182,13 @@ func (m Model) renderFooter() string {
case modePasswordSet: case modePasswordSet:
return withStatus(" tab·next field enter·set password esc·cancel") 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: default:
switch m.activeSection { switch m.activeSection {
case sectionIncidents: 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: case sectionAlerts:
return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit") return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
case sectionStats: case sectionStats:
@@ -173,7 +198,7 @@ func (m Model) renderFooter() string {
case sectionSchedule: case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit") return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit")
case sectionUsers: 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())) return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
} }
+12 -2
View File
@@ -7,6 +7,7 @@ import (
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/config" "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/theme"
"git.ryuvia.com/niklas/terdut-tui/internal/tui" "git.ryuvia.com/niklas/terdut-tui/internal/tui"
"git.ryuvia.com/niklas/terdut-tui/internal/updater" "git.ryuvia.com/niklas/terdut-tui/internal/updater"
@@ -45,8 +46,17 @@ func main() {
os.Exit(1) os.Exit(1)
} }
client := api.NewClient(cfg.ServerURL, cfg.APIKey) client := api.NewClient(cfg.ServerURL)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).WithDefaultTeam(cfg.Team) 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()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {