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
+169 -1
View File
@@ -1,7 +1,9 @@
package tui
import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
@@ -13,6 +15,14 @@ import (
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// A 401 from anything means the server no longer honours the session: it
// expired, was ended from the web UI, or the account was disabled. Whatever
// was being done cannot succeed, so go back to the sign-in form and say why,
// rather than leaving every action to fail with "server returned 401".
if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin {
return m.requireLogin("your session has ended — sign in again"), forgetSessionCmd()
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
@@ -33,6 +43,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Dashboard messages ────────────────────────────────────────────────
case loginDoneMsg:
m.loggingIn = false
m.loginErr = ""
m.loginNote = ""
m.loginInputs[loginPassword].Reset()
m.blurLoginForm()
m.mode = modeDashboard
m.err = nil
return m, connectCmd(m.client)
case loginErrMsg:
m.loggingIn = false
m.loginErr = loginErrorText(msg.err)
m.loginInputs[loginPassword].Reset()
m.focusLogin(loginPassword)
return m, nil
case logoutDoneMsg:
return m.requireLogin("you have signed out"), nil
case connectedMsg:
firstConnect := len(m.teams) == 0
m.connected = true
@@ -52,8 +82,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildIncidentTable()
m.rebuildTable()
m.rebuildArchivedTable()
var tick tea.Cmd
if !m.ticking {
m.ticking = true
tick = tickCmd(m.refreshInterval)
}
return m, tea.Batch(
tickCmd(m.refreshInterval),
tick,
fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter),
fetchStatsCmd(m.client),
statusCmd,
@@ -338,6 +373,14 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case modeAPIKeyMenu, modeAPIKeyReveal:
return m.handleKey(msg)
case modeLogin:
var inputCmd tea.Cmd
if !m.loggingIn {
m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg)
}
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modePasswordSet:
var inputCmd tea.Cmd
if !m.pwLoading {
@@ -407,6 +450,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleAPIKeyMenuKey(msg)
case modePasswordSet:
return m.handlePasswordKey(msg)
case modeLogin:
return m.handleLoginKey(msg)
case modeAPIKeyCreate:
return m.handleAPIKeyCreateKey(msg)
case modeAPIKeyReveal:
@@ -456,6 +501,13 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
return m, nil
case "L":
if !m.connected {
return m, nil
}
m.statusMsg = "Signing out…"
return m, logoutCmd(m.client)
case "T":
if !m.connected || len(m.teams) == 0 {
return m, nil
@@ -1397,3 +1449,119 @@ func (m Model) handlePasswordKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
return m, nil
}
// ── Sign in ───────────────────────────────────────────────────────────────────
// msgError digs the error out of the messages that carry one, so the 401 check
// in Update covers them all in one place.
func msgError(msg tea.Msg) error {
switch msg := msg.(type) {
case connectErrMsg:
return msg.err
case fetchDataErrMsg:
return msg.err
case detailErrMsg:
return msg.err
case actionErrMsg:
return msg.err
case detailStatsErrMsg:
return msg.err
case scheduleFetchErrMsg:
return msg.err
case scheduleActionErrMsg:
return msg.err
case userActionErrMsg:
return msg.err
}
return nil
}
// requireLogin returns to the sign-in form with everything the previous session
// loaded dropped, so a different account never sees the last one's incidents.
func (m Model) requireLogin(note string) Model {
name := m.loginInputs[loginUsername].Value()
fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme)
fresh.width, fresh.height = m.width, m.height
fresh.defaultTeam = m.defaultTeam
fresh.ticking = m.ticking
fresh.mode = modeLogin
fresh.loginInputs[loginUsername].SetValue(name)
fresh.loginNote = note
fresh.focusLogin(loginUsername)
if name != "" {
fresh.focusLogin(loginPassword)
}
fresh.rebuildIncidentTable()
fresh.rebuildTable()
fresh.rebuildArchivedTable()
fresh.rebuildScheduleTable()
fresh.rebuildUserPickerTable()
fresh.rebuildUserManageTable()
return fresh
}
func (m *Model) blurLoginForm() {
for i := range m.loginInputs {
m.loginInputs[i].Blur()
}
}
func (m *Model) focusLogin(field int) {
m.blurLoginForm()
m.loginFocus = field
m.loginInputs[field].Focus()
}
// loginErrorText turns a failed sign-in into something to act on. The server
// answers a wrong password, an unknown user and an account with no password with
// the same 401, so the last one has to be named here or it reads as a typo.
func loginErrorText(err error) string {
var se *api.StatusError
if errors.As(err, &se) {
switch se.Code {
case http.StatusUnauthorized:
return "invalid username or password — an account with no password cannot sign in; set one in the web UI first"
case http.StatusTooManyRequests:
return "too many attempts — wait a few minutes and try again"
}
}
return err.Error()
}
func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "esc":
return m, tea.Quit
}
if m.loggingIn {
return m, nil
}
switch msg.String() {
case "tab", "shift+tab", "down", "up":
next := loginPassword
if m.loginFocus == loginPassword {
next = loginUsername
}
m.focusLogin(next)
return m, nil
case "enter":
username := strings.TrimSpace(m.loginInputs[loginUsername].Value())
password := m.loginInputs[loginPassword].Value()
switch {
case username == "":
m.loginErr = "enter your username"
m.focusLogin(loginUsername)
return m, nil
case password == "":
m.loginErr = "enter your password"
m.focusLogin(loginPassword)
return m, nil
}
m.loggingIn = true
m.loginErr = ""
return m, loginCmd(m.client, m.serverURL, username, password)
}
return m, nil
}