3 Commits

Author SHA1 Message Date
Niklas Ye ee25552a53 Sign in through the server's single sign-on, with a code
CI / test (push) Successful in 17s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 23s
The sign-in screen asks the server how it can be signed in to
(GET /api/auth/config) and offers what it finds: the password form, and
"Sign in with <provider>" when the server can do a device login. The TUI
shows a link and a short code, the person approves it in any browser, and
the next poll hands over the ordinary session, so it works over SSH where
no browser can be opened. The terminal never talks to the identity
provider.

The password form is hidden when the server has turned password login
off. `auth: sso` in config.yaml starts the SSO login straight away, but not
right after signing out, where that would sign the person straight back
in; any other value is refused when the config is read. Polling honours the
server's interval, backs off on slow_down, and gives up after repeated
failures rather than retrying forever.

A server without /api/auth/config answers 404 and is treated as passwords
only, so the sign-in screen is the one it had. Needs terdut-server v0.29.0
for SSO.
2026-09-26 21:47:13 +02:00
Niklas Ye 057302cb39 Show similar earlier incidents and let notes be marked as the fix
The incident view gets a "Seen before" section from the server's new
/similar endpoint; an older server without it just shows nothing. C adds a
note as the resolution note, alongside c for a plain note. Needs the
server release that adds /similar.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 15:42:25 +02:00
Niklas Ye 451ce99a62 Say plainly that stats cover all teams
CI / test (push) Successful in 3s
The README said stats were "not team-scoped by the server", which reads
as though they were unscoped. They are scoped, to all of the caller's
teams (TestTeams_AlertsAndStatsAreScoped in terdut-server); what they
cannot do is narrow to one. Docs only, no code change.
2026-09-25 13:10:41 +02:00
13 changed files with 1076 additions and 38 deletions
+2 -2
View File
@@ -32,8 +32,8 @@ The schedule is one team's rota, so the Schedule section shows the active team,
or with *all* showing the first team you own. Only a team's owners, and
administrators, can change its rota; anyone else gets the reason in the status
bar instead of a picker. The picker offers only that team's members, because the
server refuses anybody else. Stats are not team-scoped by the server and always
cover all your teams.
server refuses anybody else. Stats always cover all your teams; the server
cannot narrow them to one.
Administrators are the only users who can create or delete users, or act on
someone else's password, topic or API keys. Everyone can manage their own.
+108 -3
View File
@@ -79,6 +79,98 @@ func (c *Client) Login(username, password string) (string, error) {
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
}
// AuthConfig asks how the server can be signed in to. It is unauthenticated, so
// it works before anybody has signed in.
func (c *Client) AuthConfig() (AuthConfig, error) {
var cfg AuthConfig
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/api/auth/config", nil)
if err != nil {
return cfg, err
}
req.Header.Set("Accept", "application/json")
err = c.do(req, &cfg)
return cfg, err
}
// The ways a device login poll can end other than with a session.
var (
// ErrDevicePending means nobody has approved yet: poll again after the
// interval.
ErrDevicePending = errors.New("waiting for approval")
// ErrDeviceSlowDown means the server was polled faster than it asked. It is
// not a failure; poll again, a little slower.
ErrDeviceSlowDown = errors.New("polling too fast")
// ErrDeviceExpired means the person took too long, or the server forgot the
// login. ErrDeviceDenied means they refused it.
ErrDeviceExpired = errors.New("the sign-in expired")
ErrDeviceDenied = errors.New("the sign-in was refused")
)
// StartDeviceLogin asks the server to begin a device login.
func (c *Client) StartDeviceLogin() (*DeviceLogin, error) {
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device", struct{}{})
if err != nil {
return nil, err
}
req.Header.Del("Cookie")
var d DeviceLogin
if err := c.do(req, &d); err != nil {
return nil, err
}
if d.DeviceCode == "" || d.UserCode == "" || d.VerificationURL == "" {
return nil, errors.New("server started a sign-in but sent no code")
}
return &d, nil
}
// PollDeviceLogin asks whether the person has approved. On approval it returns
// the session token, which the client also keeps; until then it returns one of
// the ErrDevice* errors.
func (c *Client) PollDeviceLogin(deviceCode string) (string, error) {
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device/token",
struct {
DeviceCode string `json:"device_code"`
}{deviceCode})
if err != nil {
return "", err
}
req.Header.Del("Cookie")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusAccepted:
return "", ErrDevicePending
case http.StatusTooManyRequests:
return "", ErrDeviceSlowDown
case http.StatusGone:
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "denied" {
return "", ErrDeviceDenied
}
return "", ErrDeviceExpired
}
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")
@@ -276,6 +368,18 @@ func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) {
return events, c.do(req, &events)
}
// GetSimilarIncidents lists earlier resolved incidents that look like this one
// and have notes. Servers before the similar-incidents endpoint answer 404; the
// caller treats any error as "nothing to show".
func (c *Client) GetSimilarIncidents(id int64) ([]SimilarIncident, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/similar", id))
if err != nil {
return nil, err
}
var similar []SimilarIncident
return similar, c.do(req, &similar)
}
func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
if err != nil {
@@ -357,10 +461,11 @@ func (c *Client) UnarchiveIncident(id int64) error {
return c.do(req, nil)
}
// AddNote appends a note to the incident's timeline.
func (c *Client) AddNote(incidentID int64, content string) (*IncidentEvent, error) {
// AddNote appends a note to the incident's timeline. pinned files it as the
// resolution note: what fixed the incident, shown on similar ones later.
func (c *Client) AddNote(incidentID int64, content string, pinned bool) (*IncidentEvent, error) {
req, err := c.newRequestWithBody(http.MethodPost,
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]string{"content": content})
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]any{"content": content, "pinned": pinned})
if err != nil {
return nil, err
}
+106 -1
View File
@@ -165,8 +165,10 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodPost, "/api/incidents/7/archive", ""},
{"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) },
http.MethodDelete, "/api/incidents/7/archive", ""},
{"add note", func(c *Client) error { _, err := c.AddNote(7, "hi"); return err },
{"add note", func(c *Client) error { _, err := c.AddNote(7, "hi", false); return err },
http.MethodPost, "/api/incidents/7/notes", ""},
{"similar", func(c *Client) error { _, err := c.GetSimilarIncidents(7); return err },
http.MethodGet, "/api/incidents/7/similar", `[]`},
{"delete note", func(c *Client) error { return c.DeleteNote(7, 12) },
http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
@@ -582,3 +584,106 @@ func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
t.Errorf("message changed: %q", err.Error())
}
}
func TestAuthConfig_ReadsWhatTheServerOffers(t *testing.T) {
c, got := stub(t, http.StatusOK,
`{"password_login":false,"oidc":{"enabled":true,"name":"Authentik"},"device_login":true}`)
cfg, err := c.AuthConfig()
if err != nil {
t.Fatal(err)
}
if got.method != http.MethodGet || got.path != "/api/auth/config" {
t.Errorf("wrong request: %s %s", got.method, got.path)
}
if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" || !cfg.DeviceLogin {
t.Errorf("config: %+v", cfg)
}
}
func TestAuthConfig_OldServerAnswers404(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
_, err := c.AuthConfig()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Errorf("want a 404 StatusError, got %v", err)
}
}
func TestStartDeviceLogin_SendsNoSessionAndReturnsTheCodes(t *testing.T) {
c, got := stub(t, http.StatusOK, `{"device_code":"dev","user_code":"BCDF-GHJK",
"verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`)
d, err := c.StartDeviceLogin()
if err != nil {
t.Fatal(err)
}
if got.method != http.MethodPost || got.path != "/api/oidc/device" {
t.Errorf("wrong request: %s %s", got.method, got.path)
}
// A stale session must not ride along on a request that replaces it.
if got.cookie != "" {
t.Errorf("sent the old session %q", got.cookie)
}
if d.DeviceCode != "dev" || d.UserCode != "BCDF-GHJK" || d.Interval != 5 || d.ExpiresIn != 600 ||
d.VerificationURL != "https://terdut.example.com/device?code=BCDF-GHJK" {
t.Errorf("login: %+v", d)
}
}
func TestStartDeviceLogin_AReplyWithoutCodesIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{}`)
if _, err := c.StartDeviceLogin(); err == nil {
t.Error("an empty reply must not be taken for a started login")
}
}
func TestPollDeviceLogin_Outcomes(t *testing.T) {
for _, tc := range []struct {
name string
status int
body string
want error
}{
{"pending", http.StatusAccepted, `{"status":"pending"}`, ErrDevicePending},
{"slow down", http.StatusTooManyRequests, `{"error":"slow_down"}`, ErrDeviceSlowDown},
{"expired", http.StatusGone, `{"error":"expired"}`, ErrDeviceExpired},
{"denied", http.StatusGone, `{"error":"denied"}`, ErrDeviceDenied},
} {
t.Run(tc.name, func(t *testing.T) {
c, got := stub(t, tc.status, tc.body)
tok, err := c.PollDeviceLogin("dev")
if !errors.Is(err, tc.want) || tok != "" {
t.Errorf("got %q, %v; want %v", tok, err, tc.want)
}
if got.path != "/api/oidc/device/token" || !strings.Contains(got.body, `"device_code":"dev"`) {
t.Errorf("wrong request: %s %s", got.path, got.body)
}
if c.HasSession() && c.session == "" {
t.Error("session state corrupted")
}
})
}
}
func TestPollDeviceLogin_ApprovalKeepsTheSessionFromTheCookie(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "granted"})
io.WriteString(w, `{"user":{}}`)
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
tok, err := c.PollDeviceLogin("dev")
if err != nil || tok != "granted" {
t.Fatalf("got %q, %v", tok, err)
}
if !c.HasSession() {
t.Error("the client must keep the session it was given")
}
}
func TestPollDeviceLogin_ApprovalWithoutACookieIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{"user":{}}`)
c.SetSession("")
if _, err := c.PollDeviceLogin("dev"); err == nil {
t.Error("a 200 with no session cookie is not a sign-in")
}
}
+45
View File
@@ -32,6 +32,35 @@ type Alert struct {
ResolutionSource *string `json:"resolution_source,omitempty"`
}
// AuthConfig is how the server can be signed in to, from the unauthenticated
// GET /api/auth/config. A server too old to have the endpoint answers 404, which
// callers treat as "passwords only".
type AuthConfig struct {
PasswordLogin bool `json:"password_login"`
OIDC struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
} `json:"oidc"`
// DeviceLogin is whether the server can sign in a client that has no browser,
// by showing a code (see StartDeviceLogin).
DeviceLogin bool `json:"device_login"`
}
// DeviceLogin is a sign-in the server has started for this client: the person
// opens VerificationURL, checks UserCode, and approves; the client polls with
// DeviceCode until the server hands over a session.
type DeviceLogin struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
// Interval is how many seconds to wait between polls, and ExpiresIn how many
// the person has to approve.
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
// Incident statuses.
const (
StatusTriggered = "triggered"
@@ -108,6 +137,10 @@ const (
EventResolved = "resolved"
EventNote = "note"
// A note marked as what fixed the incident. The server leads similar
// incidents with these.
EventResolutionNote = "resolution_note"
// Written when a team's dead man's switch stops reporting.
EventDeadmanSilent = "deadman_silent"
@@ -249,3 +282,15 @@ type APIKey struct {
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
}
// SimilarIncident is an earlier, resolved incident with the same signature
// (alert name plus stable group labels) as the one being viewed. ResolutionNotes
// are its "what fixed it" notes; NoteCount counts its plain notes.
type SimilarIncident struct {
ID int64 `json:"id"`
Title string `json:"title"`
TriggeredAt time.Time `json:"triggered_at"`
ResolvedAt time.Time `json:"resolved_at"`
NoteCount int `json:"note_count"`
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
}
+15 -1
View File
@@ -24,6 +24,12 @@ type Config struct {
// Team is the team to start on, by name or id. Empty shows every team the
// key's user belongs to.
Team string
// Auth is how to sign in when the server offers a choice: "sso" starts a
// single sign-on login straight away, "password" (or empty) shows the
// password form. The server decides what is on offer; this only picks the
// default among it.
Auth string
}
type rawConfig struct {
@@ -33,6 +39,7 @@ type rawConfig struct {
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"`
Team string `yaml:"team,omitempty"`
Auth string `yaml:"auth,omitempty"`
}
func Load() (*Config, error) {
@@ -45,7 +52,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 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("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\n auth: sso # optional, sso or password: how to sign in by default", path)
}
return nil, fmt.Errorf("cannot read config file: %w", err)
}
@@ -59,6 +66,12 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("config: 'server_url' is required")
}
switch raw.Auth {
case "", "password", "sso":
default:
return nil, fmt.Errorf("config: 'auth' must be sso or password, not %q", raw.Auth)
}
interval := defaultRefreshInterval
if raw.RefreshInterval > 0 {
interval = time.Duration(raw.RefreshInterval) * time.Second
@@ -71,5 +84,6 @@ func Load() (*Config, error) {
RefreshInterval: interval,
Theme: raw.Theme,
Team: raw.Team,
Auth: raw.Auth,
}, nil
}
+23
View File
@@ -59,3 +59,26 @@ func TestLoad_NoAPIKeyNeeded(t *testing.T) {
t.Error("expected the leftover api_key to be noted")
}
}
func TestLoad_AuthIsOptionalAndChecked(t *testing.T) {
for _, tc := range []struct {
yaml, want string
bad bool
}{
{"", "", false},
{"auth: password\n", "password", false},
{"auth: sso\n", "sso", false},
{"auth: oidc\n", "", true},
} {
writeConfig(t, "server_url: https://terdut.example.com\n"+tc.yaml)
cfg, err := Load()
switch {
case tc.bad && err == nil:
t.Errorf("%q: expected an error", tc.yaml)
case !tc.bad && err != nil:
t.Errorf("%q: %v", tc.yaml, err)
case !tc.bad && cfg.Auth != tc.want:
t.Errorf("%q: auth %q, want %q", tc.yaml, cfg.Auth, tc.want)
}
}
}
+15 -2
View File
@@ -27,8 +27,21 @@ func TestStartsOnTheFormWithoutASession(t *testing.T) {
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")
// Nothing is connected without a session. The one thing started is asking
// how the server can be signed in to, and a server too old to be asked (404)
// must leave the password form as it was.
srv := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(srv.Close)
cmd := signedOut(srv.URL).Init()
if cmd == nil {
t.Fatal("expected the form to ask the server how it can be signed in to")
}
msg, ok := cmd().(authConfigMsg)
if !ok {
t.Fatalf("expected authConfigMsg, got %#v", cmd())
}
if !msg.cfg.PasswordLogin || msg.cfg.DeviceLogin {
t.Errorf("an old server offers passwords only, got %+v", msg.cfg)
}
}
+122 -6
View File
@@ -126,6 +126,48 @@ type connectedMsg struct {
type connectErrMsg struct{ err error }
type loginDoneMsg struct{}
type loginErrMsg struct{ err error }
// Single sign-on. A device login is a chain: the server hands out a code
// (deviceStartedMsg), the client waits out the interval (devicePollMsg), asks
// (devicePendingMsg, or loginDoneMsg on approval), and waits again. Every message
// carries the attempt it belongs to, so the late answers of an attempt that was
// cancelled or replaced are dropped rather than acted on.
type authConfigMsg struct{ cfg api.AuthConfig }
type deviceStartedMsg struct {
attempt int
login api.DeviceLogin
}
type devicePollMsg struct{ attempt int }
type devicePendingMsg struct {
attempt int
slower bool // the server asked for fewer polls
err error // a poll that failed in a way worth retrying (network, 5xx)
}
type deviceFailedMsg struct {
attempt int
err error
}
// ssoLogin is a device login in progress; the zero value is none. attempt only
// ever goes up: starting, cancelling and finishing all bump it, which is what
// makes the messages of an earlier attempt stale.
type ssoLogin struct {
attempt int
active bool // started, and not yet cancelled, failed or finished
login *api.DeviceLogin // nil until the server has answered
// interval is the wait between polls: the server's, lengthened when it says
// it is being asked too often.
interval time.Duration
// failures counts polls in a row that failed for a reason other than "not
// yet", so a dead connection ends the wait instead of spinning forever.
failures int
}
const (
defaultDevicePoll = 5 * time.Second
maxPollFailures = 3
)
type logoutDoneMsg struct{}
type incidentsFetchedMsg struct{ incidents []api.Incident }
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
@@ -146,6 +188,7 @@ type clearStatusMsg struct{}
type incidentDetailFetchedMsg struct {
incident api.Incident
timeline []api.IncidentEvent
similar []api.SimilarIncident
}
type alertDetailFetchedMsg struct{ alert api.Alert }
type detailErrMsg struct{ err error }
@@ -227,6 +270,12 @@ type Model struct {
loginNote string
ticking bool
// How the server can be signed in to, nil until it has answered. authPref is
// the config's `auth`, which only chooses among what the server offers.
authInfo *api.AuthConfig
authPref string
sso ssoLogin
// Connection & dashboard
connected bool
loading bool
@@ -253,7 +302,9 @@ type Model struct {
// Incident detail
selectedIncident api.Incident
timeline []api.IncidentEvent
similar []api.SimilarIncident
noteCursor int
notePinned bool // the note being typed is the resolution note
detailLoading bool
detailViewport viewport.Model
@@ -468,6 +519,13 @@ func (m Model) WithDefaultTeam(team string) Model {
return m
}
// WithAuth sets the default way to sign in, from the config's `auth`: "sso"
// starts a single sign-on login by itself when the server offers one.
func (m Model) WithAuth(pref string) Model {
m.authPref = pref
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)
@@ -485,7 +543,10 @@ func (m Model) WithLogin(username, note string) Model {
// already showing and there is nothing to do until it is submitted.
func (m Model) Init() tea.Cmd {
if m.mode == modeLogin {
return nil
// Ask how the server can be signed in to, so the form offers the right
// thing. Nothing depends on the answer arriving: the password form is
// already usable.
return authConfigCmd(m.client)
}
return connectCmd(m.client)
}
@@ -614,7 +675,7 @@ func (m *Model) refreshDetailContent() {
return
}
m.detailViewport.SetContent(
buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.noteCursor, m.width))
buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.similar, m.noteCursor, m.width))
}
func (m *Model) refreshStatsContent() {
@@ -746,7 +807,7 @@ func userFlags(u api.User) string {
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
notes := make([]api.IncidentEvent, 0, len(timeline))
for _, e := range timeline {
if e.Type == api.EventNote {
if e.Type == api.EventNote || e.Type == api.EventResolutionNote {
notes = append(notes, e)
}
}
@@ -1050,6 +1111,58 @@ func loginCmd(client *api.Client, serverURL, username, password string) tea.Cmd
}
}
// authConfigCmd asks how the server can be signed in to. A server too old to be
// asked, or one that cannot be reached, is treated as offering passwords only: the
// form that always existed is the safe fallback, and it reports a real connection
// problem itself when it is submitted.
func authConfigCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
cfg, err := client.AuthConfig()
if err != nil {
cfg = api.AuthConfig{PasswordLogin: true}
}
return authConfigMsg{cfg}
}
}
// startDeviceCmd asks the server to begin a device login.
func startDeviceCmd(client *api.Client, attempt int) tea.Cmd {
return func() tea.Msg {
login, err := client.StartDeviceLogin()
if err != nil {
return deviceFailedMsg{attempt, err}
}
return deviceStartedMsg{attempt, *login}
}
}
// devicePollAfter waits out the interval before the next poll.
func devicePollAfter(attempt int, interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(time.Time) tea.Msg { return devicePollMsg{attempt} })
}
// pollDeviceCmd asks whether the login has been approved. Approval saves the
// session the way a password sign-in does, and ends in the same loginDoneMsg.
func pollDeviceCmd(client *api.Client, serverURL string, attempt int, deviceCode string) tea.Cmd {
return func() tea.Msg {
token, err := client.PollDeviceLogin(deviceCode)
switch {
case err == nil:
_ = session.Save(serverURL, token)
return loginDoneMsg{}
case errors.Is(err, api.ErrDevicePending):
return devicePendingMsg{attempt: attempt}
case errors.Is(err, api.ErrDeviceSlowDown):
return devicePendingMsg{attempt: attempt, slower: true}
case errors.Is(err, api.ErrDeviceExpired), errors.Is(err, api.ErrDeviceDenied):
return deviceFailedMsg{attempt, err}
}
// Anything else is the network or the server having a moment, which a
// person waiting on a browser should not have to start over for.
return devicePendingMsg{attempt: attempt, err: err}
}
}
// 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.
@@ -1131,7 +1244,10 @@ func incidentDetail(client *api.Client, id int64) tea.Msg {
if err != nil {
return detailErrMsg{err}
}
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline}
// Best effort: an older server has no such endpoint, and the incident is
// still worth showing without it.
similar, _ := client.GetSimilarIncidents(id)
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline, similar: similar}
}
func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd {
@@ -1185,9 +1301,9 @@ func unsnoozeIncidentCmd(client *api.Client, id int64) tea.Cmd {
return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) })
}
func addNoteCmd(client *api.Client, id int64, content string) tea.Cmd {
func addNoteCmd(client *api.Client, id int64, content string, pinned bool) tea.Cmd {
return incidentActionCmd(client, id, func() error {
_, err := client.AddNote(id, content)
_, err := client.AddNote(id, content, pinned)
return err
})
}
+378
View File
@@ -0,0 +1,378 @@
package tui
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
tea "github.com/charmbracelet/bubbletea"
)
// fakeServer is the device-login half of terdut-server: it starts a login,
// answers polls with whatever poll says, and records what it was asked.
type fakeServer struct {
*httptest.Server
mu sync.Mutex
polls int
// poll is called for each poll and writes the response.
poll func(w http.ResponseWriter, n int)
}
func newFakeServer(t *testing.T) *fakeServer {
t.Helper()
f := &fakeServer{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/oidc/device":
io.WriteString(w, `{"device_code":"dev-1","user_code":"BCDF-GHJK",
"verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`)
case "/api/oidc/device/token":
f.mu.Lock()
f.polls++
n := f.polls
f.mu.Unlock()
var body struct {
DeviceCode string `json:"device_code"`
}
json.NewDecoder(r.Body).Decode(&body)
if body.DeviceCode != "dev-1" {
t.Errorf("polled with %q", body.DeviceCode)
}
f.poll(w, n)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(f.Close)
return f
}
func pending(w http.ResponseWriter, _ int) {
w.WriteHeader(http.StatusAccepted)
io.WriteString(w, `{"status":"pending"}`)
}
// offering is a signed-out model that has been told what the server offers.
func offering(t *testing.T, url string, cfg api.AuthConfig, pref string) (Model, tea.Cmd) {
t.Helper()
m := signedOut(url).WithAuth(pref)
next, cmd := m.Update(authConfigMsg{cfg})
return next.(Model), cmd
}
func both() api.AuthConfig {
c := api.AuthConfig{PasswordLogin: true, DeviceLogin: true}
c.OIDC.Enabled, c.OIDC.Name = true, "Authentik"
return c
}
func ssoOnly() api.AuthConfig {
c := both()
c.PasswordLogin = false
return c
}
func update(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) {
t.Helper()
next, cmd := m.Update(msg)
return next.(Model), cmd
}
func ctrlO() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlO} }
func TestSSO_OfferedAlongsidePasswordsIsNotStartedByItself(t *testing.T) {
m, cmd := offering(t, "http://test", both(), "")
if cmd != nil || m.sso.active {
t.Fatal("with passwords on offer nothing should start until asked")
}
view := m.View()
for _, want := range []string{"Username:", "Password:", "ctrl+o to sign in with Authentik"} {
if !strings.Contains(view, want) {
t.Errorf("expected %q on the form:\n%s", want, view)
}
}
}
func TestSSO_NotOfferedShowsNoSuchHint(t *testing.T) {
m, _ := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "")
if strings.Contains(m.View(), "ctrl+o") {
t.Error("a server with no SSO must not advertise it")
}
if m, cmd := update(t, m, ctrlO()); cmd != nil || m.sso.active {
t.Error("ctrl+o must do nothing when the server has no SSO")
}
}
func TestSSO_FullFlow(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
f := newFakeServer(t)
f.poll = func(w http.ResponseWriter, n int) {
if n < 3 {
pending(w, n)
return
}
http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-sso", Path: "/"})
io.WriteString(w, `{"user":{}}`)
}
m, _ := offering(t, f.URL, both(), "")
// ctrl+o asks the server for a login.
m, cmd := update(t, m, ctrlO())
if !m.sso.active || cmd == nil {
t.Fatal("ctrl+o should start a single sign-on login")
}
if !strings.Contains(m.View(), "Contacting the server") {
t.Errorf("before the server answers:\n%s", m.View())
}
started, ok := cmd().(deviceStartedMsg)
if !ok {
t.Fatalf("expected deviceStartedMsg, got %#v", cmd())
}
// The link and the code are shown, and a poll is scheduled.
m, cmd = update(t, m, started)
if cmd == nil || m.sso.interval != 5*time.Second {
t.Fatalf("expected a poll to be scheduled every 5s, got cmd %v interval %v", cmd != nil, m.sso.interval)
}
view := m.View()
for _, want := range []string{"Sign in with Authentik", "https://terdut.example.com/device?code=BCDF-GHJK",
"BCDF-GHJK", "Waiting for approval", "10 minutes", "esc·cancel"} {
if !strings.Contains(view, want) {
t.Errorf("expected %q while waiting:\n%s", want, view)
}
}
if strings.Contains(view, "Username:") {
t.Error("the password form must be out of the way while waiting")
}
// Two polls that find nothing, each scheduling the next.
for i := 1; i <= 2; i++ {
m, cmd = update(t, m, devicePollMsg{m.sso.attempt})
if cmd == nil {
t.Fatalf("poll %d: expected a request", i)
}
pend, ok := cmd().(devicePendingMsg)
if !ok {
t.Fatalf("poll %d: expected devicePendingMsg", i)
}
if m, cmd = update(t, m, pend); cmd == nil || !m.sso.active {
t.Fatalf("poll %d: expected to keep waiting", i)
}
}
// The third is approved: the session is saved and it moves on and connects.
m, cmd = update(t, m, devicePollMsg{m.sso.attempt})
done := cmd()
if _, ok := done.(loginDoneMsg); !ok {
t.Fatalf("expected loginDoneMsg, got %#v", done)
}
if got := session.Load(f.URL); got != "tok-sso" {
t.Errorf("session saved for next time: %q", got)
}
m, connect := update(t, m, done)
if m.mode != modeDashboard || m.sso.active || connect == nil {
t.Errorf("expected to move on and connect: mode %v active %v", m.mode, m.sso.active)
}
if f.polls != 3 {
t.Errorf("%d polls, want 3", f.polls)
}
}
func TestSSO_EscCancelsBeforeItQuits(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "AAAA-BBBB", VerificationURL: "u", Interval: 5, ExpiresIn: 600}})
m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc})
if cmd != nil {
t.Fatal("the first esc backs out of the wait; it must not quit")
}
if m.sso.active || m.mode != modeLogin || !strings.Contains(m.View(), "Username:") {
t.Errorf("expected the password form back, active %v", m.sso.active)
}
if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc}); cmd == nil {
t.Error("esc on the form quits, as it always did")
} else if _, ok := cmd().(tea.QuitMsg); !ok {
t.Errorf("expected a quit, got %#v", cmd())
}
}
// The answers of an attempt that was cancelled arrive late and must change nothing.
func TestSSO_StaleMessagesAreIgnored(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
old := m.sso.attempt
m = m.cancelSSO()
for name, msg := range map[string]tea.Msg{
"started": deviceStartedMsg{old, api.DeviceLogin{DeviceCode: "d", UserCode: "X", Interval: 5}},
"poll": devicePollMsg{old},
"pending": devicePendingMsg{attempt: old},
"failed": deviceFailedMsg{old, api.ErrDeviceExpired},
} {
next, cmd := update(t, m, msg)
if cmd != nil || next.sso.active || next.sso.login != nil || next.loginErr != "" {
t.Errorf("%s from a cancelled attempt was acted on: %+v err %q", name, next.sso, next.loginErr)
}
}
// A new attempt is not confused by the old one's messages either.
m, _ = update(t, m, ctrlO())
if m.sso.attempt == old {
t.Fatal("a new attempt must have a new number")
}
if _, cmd := update(t, m, devicePollMsg{old}); cmd != nil {
t.Error("the old attempt's poll must not run in the new one")
}
}
func TestSSO_NoPasswordsStartsByItselfAndEnterRestarts(t *testing.T) {
m, cmd := offering(t, "http://test", ssoOnly(), "")
if !m.sso.active || cmd == nil {
t.Fatal("a server with no passwords should start signing in with SSO straight away")
}
m = m.cancelSSO()
view := m.View()
if strings.Contains(view, "Username:") || !strings.Contains(view, "This server signs in with Authentik") {
t.Errorf("no password form on an SSO-only server:\n%s", view)
}
if !strings.Contains(view, "enter·sign in with Authentik") {
t.Errorf("the footer should say what enter does:\n%s", view)
}
if m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); !m.sso.active || cmd == nil {
t.Error("enter should start it again")
}
}
func TestSSO_ConfigPrefersItWhenOffered(t *testing.T) {
if m, cmd := offering(t, "http://test", both(), "sso"); !m.sso.active || cmd == nil {
t.Error("auth: sso should start by itself when the server offers it")
}
// ...and shows the password form when it does not, rather than a dead end.
m, cmd := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "sso")
if m.sso.active || cmd != nil || !strings.Contains(m.View(), "Username:") {
t.Error("auth: sso against a server without SSO must fall back to the form")
}
if m, cmd := offering(t, "http://test", both(), "password"); m.sso.active || cmd != nil {
t.Error("auth: password must not start SSO")
}
}
func TestSSO_ExpiredAndRefusedReturnToTheFormWithAReason(t *testing.T) {
for name, tc := range map[string]struct {
err error
want string
}{
"expired": {api.ErrDeviceExpired, "expired"},
"refused": {api.ErrDeviceDenied, "refused"},
"no sso": {&api.StatusError{Code: 404}, "does not offer"},
"limited": {&api.StatusError{Code: 429}, "too many"},
"other": {errors.New("dial tcp: refused"), "Authentik failed"},
} {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, cmd := update(t, m, deviceFailedMsg{m.sso.attempt, tc.err})
if cmd != nil || m.sso.active || !strings.Contains(m.loginErr, tc.want) {
t.Errorf("%s: active %v err %q, want it to contain %q", name, m.sso.active, m.loginErr, tc.want)
}
if !strings.Contains(m.View(), tc.want) {
t.Errorf("%s: the reason is not shown:\n%s", name, m.View())
}
}
}
func TestSSO_SlowDownLengthensTheInterval(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}})
m, cmd := update(t, m, devicePendingMsg{attempt: m.sso.attempt, slower: true})
if m.sso.interval != 10*time.Second || cmd == nil {
t.Errorf("interval %v, cmd %v; want 10s and another poll", m.sso.interval, cmd != nil)
}
}
func TestSSO_ADeadConnectionEndsTheWaitButABlipDoesNot(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}})
blip := errors.New("connection reset")
// Two failures, then a good answer: the count starts over.
for range 2 {
m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip})
}
m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt})
if !m.sso.active || m.sso.failures != 0 {
t.Fatalf("a good answer should reset the failures: %+v", m.sso)
}
// Three in a row is a dead connection.
var cmd tea.Cmd
for range maxPollFailures {
m, cmd = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip})
}
if m.sso.active || cmd != nil || !strings.Contains(m.loginErr, "connection reset") {
t.Errorf("expected to give up with the reason: active %v err %q", m.sso.active, m.loginErr)
}
}
func TestSSO_TypingGoesNowhereWhileWaiting(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m = typeInto(t, m, "hunter2")
if got := m.loginInputs[m.loginFocus].Value(); got != "" {
t.Errorf("keys typed during the wait ended up in a field: %q", got)
}
if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil {
t.Error("enter during the wait must not start anything")
}
}
// After the session ends the form comes back; it must still know what the
// server offers, and follow the config's preference without a second question.
func TestSSO_SessionEndingReturnsToSSOWhenPreferred(t *testing.T) {
m, _ := offering(t, "http://test", both(), "sso")
m = m.cancelSSO()
m.mode = modeDashboard // signed in, as loginDoneMsg leaves it
m, _ = update(t, m, connectedMsg{})
m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}})
if m.mode != modeLogin || m.authInfo == nil {
t.Fatalf("expected the form with what the server offers kept: mode %v info %v", m.mode, m.authInfo)
}
if !m.sso.active || cmd == nil {
t.Error("with auth: sso an ended session should go straight to SSO")
}
}
func TestSSO_SigningOutDoesNotSignStraightBackIn(t *testing.T) {
m, _ := offering(t, "http://test", both(), "sso")
m = m.cancelSSO()
m.mode = modeDashboard
m, _ = update(t, m, connectedMsg{})
m, cmd := update(t, m, logoutDoneMsg{})
if m.mode != modeLogin || m.sso.active || cmd != nil {
t.Errorf("a deliberate sign-out must wait: mode %v active %v", m.mode, m.sso.active)
}
}
// A session that ends before the server was ever asked (the TUI started on a
// saved session) still has to learn what to offer.
func TestSSO_LearnsWhatIsOfferedWhenTheSessionEndsFirst(t *testing.T) {
c := api.NewClient("http://test")
c.SetSession("saved")
m := NewModel(c, "http://test", time.Minute, signedOut("x").theme)
m.width, m.height = 120, 40
m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}})
if m.mode != modeLogin || m.authInfo != nil || cmd == nil {
t.Errorf("expected the form and a question to the server: mode %v info %v cmd %v", m.mode, m.authInfo, cmd != nil)
}
}
+159 -7
View File
@@ -7,6 +7,7 @@ import (
"slices"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/atotto/clipboard"
@@ -20,7 +21,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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()
m, entry := m.requireLogin("your session has ended — sign in again").enterLogin(true)
return m, tea.Batch(forgetSessionCmd(), entry)
}
switch msg := msg.(type) {
@@ -45,6 +47,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case loginDoneMsg:
m.loggingIn = false
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
m.loginErr = ""
m.loginNote = ""
m.loginInputs[loginPassword].Reset()
@@ -61,7 +64,57 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case logoutDoneMsg:
return m.requireLogin("you have signed out"), nil
// Not auto-started even with auth: sso: somebody who has just signed out
// did not ask to be signed straight back in.
return m.requireLogin("you have signed out").enterLogin(false)
case authConfigMsg:
cfg := msg.cfg
m.authInfo = &cfg
if m.mode == modeLogin && m.autoStartsSSO() {
return m.startSSO()
}
return m, nil
case deviceStartedMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
login := msg.login
m.sso.login = &login
m.sso.interval = time.Duration(login.Interval) * time.Second
if m.sso.interval <= 0 {
m.sso.interval = defaultDevicePoll
}
return m, devicePollAfter(m.sso.attempt, m.sso.interval)
case devicePollMsg:
if !m.sso.current(msg.attempt) || m.sso.login == nil {
return m, nil
}
return m, pollDeviceCmd(m.client, m.serverURL, m.sso.attempt, m.sso.login.DeviceCode)
case devicePendingMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
if msg.err != nil {
if m.sso.failures++; m.sso.failures >= maxPollFailures {
return m.failSSO(msg.err), nil
}
} else {
m.sso.failures = 0
}
if msg.slower {
m.sso.interval += defaultDevicePoll
}
return m, devicePollAfter(m.sso.attempt, m.sso.interval)
case deviceFailedMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
return m.failSSO(msg.err), nil
case connectedMsg:
firstConnect := len(m.teams) == 0
@@ -145,6 +198,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case incidentDetailFetchedMsg:
m.selectedIncident = msg.incident
m.timeline = msg.timeline
m.similar = msg.similar
m.detailLoading = false
if m.noteCursor >= len(noteEvents(m.timeline)) {
m.noteCursor = -1
@@ -375,7 +429,7 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case modeLogin:
var inputCmd tea.Cmd
if !m.loggingIn {
if !m.loggingIn && !m.sso.active && m.offersPasswords() {
m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg)
}
m2, ourCmd := m.handleKey(msg)
@@ -938,8 +992,9 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDashboard
return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
case "c":
case "c", "C":
m.mode = modeNote
m.notePinned = msg.String() == "C"
m.noteInput.Reset()
m.noteInput.Focus()
m.detailViewport.Height = m.detailViewportHeight()
@@ -1032,7 +1087,7 @@ func (m Model) handleNoteKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeIncidentDetail
m.noteInput.Blur()
m.detailViewport.Height = m.detailViewportHeight()
return m, addNoteCmd(m.client, m.selectedIncident.ID, content)
return m, addNoteCmd(m.client, m.selectedIncident.ID, content, m.notePinned)
}
return m, nil
@@ -1483,6 +1538,7 @@ func (m Model) requireLogin(note string) Model {
fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme)
fresh.width, fresh.height = m.width, m.height
fresh.defaultTeam = m.defaultTeam
fresh.authInfo, fresh.authPref = m.authInfo, m.authPref
fresh.ticking = m.ticking
fresh.mode = modeLogin
fresh.loginInputs[loginUsername].SetValue(name)
@@ -1528,12 +1584,108 @@ func loginErrorText(err error) string {
return err.Error()
}
// ── Single sign-on ────────────────────────────────────────────────────────────
// current reports whether a message belongs to the attempt in progress. Anything
// else is the late answer of one that was cancelled, replaced or finished.
func (s ssoLogin) current(attempt int) bool { return s.active && attempt == s.attempt }
// canSSO is whether the server can sign in a client with no browser.
func (m Model) canSSO() bool { return m.authInfo != nil && m.authInfo.DeviceLogin }
// offersPasswords is whether the password form is worth showing. Until the
// server has answered it is: the form is what a server too old to be asked has.
func (m Model) offersPasswords() bool { return m.authInfo == nil || m.authInfo.PasswordLogin }
// autoStartsSSO is whether the form should start a single sign-on login by
// itself: when the config asks for it, and when the server has no passwords, so
// that there is nothing else to show.
func (m Model) autoStartsSSO() bool {
return m.canSSO() && !m.sso.active && (m.authPref == "sso" || !m.offersPasswords())
}
// ssoName is what the provider is called on screen.
func (m Model) ssoName() string {
if m.authInfo != nil && m.authInfo.OIDC.Name != "" {
return m.authInfo.OIDC.Name
}
return "single sign-on"
}
// enterLogin is what to do on arriving at the sign-in form other than by
// starting up: learn how the server can be signed in to if that is not known,
// and, when auto is set, start a single sign-on login if the config or the
// server's lack of passwords calls for one.
func (m Model) enterLogin(auto bool) (Model, tea.Cmd) {
if m.authInfo == nil {
return m, authConfigCmd(m.client)
}
if auto && m.autoStartsSSO() {
return m.startSSO()
}
return m, nil
}
// startSSO begins a device login, replacing any earlier attempt.
func (m Model) startSSO() (Model, tea.Cmd) {
m.sso = ssoLogin{attempt: m.sso.attempt + 1, active: true}
m.loginErr = ""
return m, startDeviceCmd(m.client, m.sso.attempt)
}
// cancelSSO abandons the attempt in progress. The server forgets the login when
// it expires; there is nothing to tell it.
func (m Model) cancelSSO() Model {
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
return m
}
// failSSO ends the attempt and says why on the form.
func (m Model) failSSO(err error) Model {
m = m.cancelSSO()
m.loginErr = ssoErrorText(err, m.ssoName())
return m
}
// ssoErrorText turns a failed single sign-on into something to act on.
func ssoErrorText(err error, name string) string {
var se *api.StatusError
switch {
case errors.Is(err, api.ErrDeviceExpired):
return "the sign-in expired before it was approved — start it again"
case errors.Is(err, api.ErrDeviceDenied):
return "the sign-in was refused in the browser"
case errors.As(err, &se) && se.Code == http.StatusNotFound:
return "this server does not offer sign-in with " + name
case errors.As(err, &se) && se.Code == http.StatusTooManyRequests:
return "too many attempts — wait a few minutes and try again"
}
return "sign-in with " + name + " failed: " + err.Error()
}
func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "esc":
case "ctrl+c":
return m, tea.Quit
case "esc":
// Backs out of a single sign-on wait before it quits the program, so a
// wrong turn does not cost the session.
if m.sso.active {
return m.cancelSSO(), nil
}
return m, tea.Quit
}
if m.loggingIn {
if m.loggingIn || m.sso.active {
return m, nil
}
if msg.String() == "ctrl+o" && m.canSSO() {
return m.startSSO()
}
// With no password form there is one thing to do, and enter does it.
if msg.String() == "enter" && !m.offersPasswords() {
if m.canSSO() {
return m.startSSO()
}
return m, nil
}
+75 -6
View File
@@ -42,17 +42,62 @@ func (m Model) renderLogin() string {
if m.loginNote != "" {
b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n")
}
if m.sso.active {
b.WriteString(m.renderSSOWait())
return b.String()
}
if m.offersPasswords() {
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")
} else {
b.WriteString(m.styles.Muted.Render(" This server signs in with "+m.ssoName()+".") + "\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")
}
if m.canSSO() && m.offersPasswords() {
b.WriteString("\n" + m.styles.Muted.Render(" or press ctrl+o to sign in with "+m.ssoName()) + "\n")
}
return b.String()
}
// renderSSOWait is the single sign-on screen: the link to open and the code to
// check against it, while the client waits for the approval.
func (m Model) renderSSOWait() string {
var b strings.Builder
l := m.sso.login
if l == nil {
b.WriteString(m.styles.Muted.Render(" Contacting the server…") + "\n")
return b.String()
}
b.WriteString(" " + m.styles.Header.Render("Sign in with "+m.ssoName()) + "\n\n")
b.WriteString(" Open this link in a browser, on any device, and approve the sign-in:\n\n")
b.WriteString(" " + m.styles.Accent.Render(l.VerificationURL) + "\n\n")
b.WriteString(" " + m.styles.Header.Render("Code: ") + m.styles.Bold.Render(l.UserCode) +
m.styles.Muted.Render(" it should match the code on that page") + "\n\n")
b.WriteString(m.styles.Muted.Render(fmt.Sprintf(" Waiting for approval… good for %d minutes", (l.ExpiresIn+59)/60)) + "\n")
return b.String()
}
// loginHelp is the sign-in footer, which depends on what the server offers.
func (m Model) loginHelp() string {
switch {
case m.sso.active:
return " esc·cancel"
case !m.offersPasswords():
if m.canSSO() {
return " enter·sign in with " + m.ssoName() + " esc·quit"
}
return " esc·quit"
case m.canSSO():
return " tab·next field enter·sign in ctrl+o·" + m.ssoName() + " esc·quit"
}
return " tab·next field enter·sign in esc·quit"
}
// activeTeamLabel names what the lists are narrowed to.
func (m Model) activeTeamLabel() string {
if t, ok := m.activeTeam(); ok {
@@ -90,7 +135,11 @@ func (m Model) renderBody() string {
case modeIncidentDetail, modeAlertDetail:
return m.renderDetail()
case modeNote:
return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View())
label := "Note: "
if m.notePinned {
label = "What fixed it: "
}
return m.renderPrompt(m.styles.Header.Render(label) + m.noteInput.View())
case modeSnooze:
return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm:
@@ -135,7 +184,7 @@ func (m Model) renderFooter() string {
switch m.mode {
case modeIncidentDetail:
if !m.selectedIncident.IsOpen() {
return withStatus(" x·archive c·note [/]·select d·del esc·back")
return withStatus(" x·archive c·note C·fix note [/]·select d·del esc·back")
}
return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del esc·back")
@@ -183,7 +232,7 @@ func (m Model) renderFooter() string {
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")
return "\n" + m.styles.Footer.Render(m.loginHelp())
default:
switch m.activeSection {
@@ -485,7 +534,7 @@ func line(style lipgloss.Style, s string) string {
// ── Content builders ───────────────────────────────────────────────────────
func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, similar []api.SimilarIncident, cursor, width int) string {
now := time.Now()
var b strings.Builder
contentW := width - 4
@@ -579,6 +628,22 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
}
b.WriteString("\n")
// Seen before: earlier incidents of the same kind that someone left notes on.
if len(similar) > 0 {
b.WriteString(divider(s, "Seen before", width))
for _, sim := range similar {
b.WriteString(fmt.Sprintf(" #%-6d %-44s %s\n", sim.ID, truncate(sim.Title, 44),
s.Muted.Render("resolved "+humanAgo(now, sim.ResolvedAt))))
for _, n := range sim.ResolutionNotes {
b.WriteString(" " + s.Resolved.Render("fixed: ") + n.Detail + "\n")
}
if len(sim.ResolutionNotes) == 0 {
b.WriteString(line(s.Muted, fmt.Sprintf(" %d note(s), no resolution note", sim.NoteCount)))
}
}
b.WriteString("\n")
}
// Timeline — the only history the server keeps.
notes := noteEvents(timeline)
b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
@@ -588,7 +653,7 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
noteIndex := 0
for _, e := range timeline {
when := s.Muted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote {
if e.Type != api.EventNote && e.Type != api.EventResolutionNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue
}
@@ -598,7 +663,11 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
marker = s.Selected.Render("> ")
author = s.Selected.Render(e.Username)
}
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
verb := " wrote"
if e.Type == api.EventResolutionNote {
verb = " noted the fix"
}
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+verb, when))
b.WriteString(" " + e.Detail + "\n")
noteIndex++
}
+25 -8
View File
@@ -57,7 +57,7 @@ func TestIncidentDetail_RendersTheWholeStory(t *testing.T) {
{Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now},
}
out := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 110)
out := buildIncidentDetailContent(testStyles(), inc, timeline, nil, -1, 110)
mustContain(t, out,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin",
@@ -77,7 +77,7 @@ func TestIncidentDetail_ShowsSnooze(t *testing.T) {
}
// The exact remaining time is humanUntil's business, not this test's — a few
// microseconds of elapsed clock turn "in 2h" into "in 1h 59m".
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
}
@@ -88,7 +88,7 @@ func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) {
Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &past,
}
if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, -1, 110)), "Snoozed:") {
if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Snoozed:") {
t.Error("an expired snooze should not be rendered")
}
}
@@ -100,18 +100,18 @@ func TestIncidentDetail_ShowsResolutionSource(t *testing.T) {
Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour),
ResolvedAt: &now, ResolutionSource: &source,
}
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110),
"RESOLVED", "Resolved:", "manual")
}
func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "nobody", "not acknowledged")
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "nobody", "not acknowledged")
}
func TestIncidentDetail_EmptyTimeline(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "Nothing recorded yet")
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "Nothing recorded yet")
}
func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
@@ -122,7 +122,7 @@ func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
}
inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now}
out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, 1, 110))
out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, nil, 1, 110))
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") {
t.Errorf("expected the selected note marked, got %q", line)
@@ -212,7 +212,7 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
Detail: "reminder: ntfy returned 502", CreatedAt: now},
}
got := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 120)
got := buildIncidentDetailContent(testStyles(), inc, timeline, nil, -1, 120)
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
}
@@ -349,3 +349,20 @@ func TestView_ZeroWidthRendersNothing(t *testing.T) {
type errFixture struct{}
func (errFixture) Error() string { return "connection refused" }
func TestIncidentDetail_ShowsSimilarWithResolutionNotes(t *testing.T) {
now := time.Now()
inc := api.Incident{Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now}
similar := []api.SimilarIncident{
{ID: 4, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-48 * time.Hour),
ResolutionNotes: []api.IncidentEvent{{Type: api.EventResolutionNote, Detail: "rotated the logs"}}},
{ID: 2, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-96 * time.Hour), NoteCount: 3},
}
out := plain(buildIncidentDetailContent(testStyles(), inc, nil, similar, -1, 110))
mustContain(t, out, "Seen before", "#4", "fixed: rotated the logs", "3 note(s), no resolution note")
// Nothing similar, no section.
if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Seen before") {
t.Error("expected no Seen before section without similar incidents")
}
}
+1
View File
@@ -56,6 +56,7 @@ func main() {
}
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).
WithDefaultTeam(cfg.Team).
WithAuth(cfg.Auth).
WithLogin(cfg.Username, note)
p := tea.NewProgram(model, tea.WithAltScreen())