Sign in as a user instead of with an API key
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:
@@ -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
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/session"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
@@ -52,6 +53,14 @@ const (
|
||||
modeAPIKeyReveal
|
||||
modeAPIKeyRevokeByID
|
||||
modePasswordSet
|
||||
modeLogin
|
||||
)
|
||||
|
||||
// Fields of the sign-in form, in tab order.
|
||||
const (
|
||||
loginUsername = iota
|
||||
loginPassword
|
||||
loginFieldCount
|
||||
)
|
||||
|
||||
// Fields of the set-password form, in tab order.
|
||||
@@ -115,6 +124,9 @@ type connectedMsg struct {
|
||||
me api.Me
|
||||
}
|
||||
type connectErrMsg struct{ err error }
|
||||
type loginDoneMsg struct{}
|
||||
type loginErrMsg struct{ err error }
|
||||
type logoutDoneMsg struct{}
|
||||
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
||||
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
||||
type incidentActionDoneMsg struct {
|
||||
@@ -188,6 +200,7 @@ type Model struct {
|
||||
client *api.Client
|
||||
serverURL string
|
||||
refreshInterval time.Duration
|
||||
theme theme.Theme // kept to build a fresh model when signing out
|
||||
|
||||
activeSection section
|
||||
mode mode
|
||||
@@ -203,6 +216,17 @@ type Model struct {
|
||||
meID int64
|
||||
isAdmin bool
|
||||
|
||||
// Sign-in. Until the server accepts a session the TUI is in modeLogin;
|
||||
// loginNote is a line the form shows above the fields (why we are here), and
|
||||
// ticking says the refresh timer is already running, so signing in again
|
||||
// after signing out does not start a second one.
|
||||
loginInputs [loginFieldCount]textinput.Model
|
||||
loginFocus int
|
||||
loggingIn bool
|
||||
loginErr string
|
||||
loginNote string
|
||||
ticking bool
|
||||
|
||||
// Connection & dashboard
|
||||
connected bool
|
||||
loading bool
|
||||
@@ -365,6 +389,18 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
||||
pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer
|
||||
}
|
||||
|
||||
var loginIn [loginFieldCount]textinput.Model
|
||||
for i, placeholder := range [loginFieldCount]string{"username", "password"} {
|
||||
loginIn[i] = textinput.New()
|
||||
loginIn[i].Placeholder = placeholder
|
||||
loginIn[i].CharLimit = 72 // bcrypt's limit, and the server refuses longer passwords
|
||||
}
|
||||
loginIn[loginPassword].EchoMode = textinput.EchoPassword
|
||||
loginIn[loginPassword].EchoCharacter = '•'
|
||||
for i := range loginIn {
|
||||
loginIn[i] = st.Input(loginIn[i])
|
||||
}
|
||||
|
||||
for _, in := range []*textinput.Model{
|
||||
¬eIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
|
||||
} {
|
||||
@@ -385,12 +421,20 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
||||
}
|
||||
window := today.AddDate(0, 0, -(weekday - 1))
|
||||
|
||||
startMode := modeDashboard
|
||||
if client != nil && !client.HasSession() {
|
||||
startMode = modeLogin
|
||||
loginIn[loginUsername].Focus()
|
||||
}
|
||||
|
||||
return Model{
|
||||
client: client,
|
||||
serverURL: serverURL,
|
||||
refreshInterval: refreshInterval,
|
||||
theme: th,
|
||||
activeSection: sectionIncidents,
|
||||
mode: modeDashboard,
|
||||
mode: startMode,
|
||||
loginInputs: loginIn,
|
||||
loading: true,
|
||||
incidentFilter: "",
|
||||
alertFilter: "firing",
|
||||
@@ -424,7 +468,25 @@ func (m Model) WithDefaultTeam(team string) Model {
|
||||
return m
|
||||
}
|
||||
|
||||
// WithLogin prefills the sign-in form's username and sets a note shown above it.
|
||||
func (m Model) WithLogin(username, note string) Model {
|
||||
m.loginInputs[loginUsername].SetValue(username)
|
||||
m.loginNote = note
|
||||
if username != "" && m.mode == modeLogin {
|
||||
// The name is known, so the only thing left to type is the password.
|
||||
m.loginInputs[loginUsername].Blur()
|
||||
m.loginFocus = loginPassword
|
||||
m.loginInputs[loginPassword].Focus()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Init tries the saved session, if there is one; otherwise the sign-in form is
|
||||
// already showing and there is nothing to do until it is submitted.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
if m.mode == modeLogin {
|
||||
return nil
|
||||
}
|
||||
return connectCmd(m.client)
|
||||
}
|
||||
|
||||
@@ -975,6 +1037,38 @@ func connectCmd(client *api.Client) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// loginCmd signs in and saves the session, so the next run can resume it. Failing
|
||||
// to save is not failing to sign in: the session works for this run either way.
|
||||
func loginCmd(client *api.Client, serverURL, username, password string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
token, err := client.Login(username, password)
|
||||
if err != nil {
|
||||
return loginErrMsg{err}
|
||||
}
|
||||
_ = session.Save(serverURL, token)
|
||||
return loginDoneMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
// logoutCmd ends the session on the server and deletes the saved one. The saved
|
||||
// copy goes even when the server cannot be reached, because the person asked to
|
||||
// be signed out and a token left on disk would say otherwise.
|
||||
func logoutCmd(client *api.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_ = client.Logout()
|
||||
_ = session.Clear()
|
||||
return logoutDoneMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
// forgetSessionCmd drops a saved session the server no longer honours.
|
||||
func forgetSessionCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_ = session.Clear()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func fetchIncidentsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
status, snoozed := incidentQuery(filter)
|
||||
|
||||
+169
-1
@@ -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
|
||||
}
|
||||
|
||||
+27
-2
@@ -34,6 +34,25 @@ func (m Model) renderHeader() string {
|
||||
return spread(title, right, m.width)
|
||||
}
|
||||
|
||||
// renderLogin is the sign-in form. It is the whole body: nothing else is shown
|
||||
// until the server has accepted a session.
|
||||
func (m Model) renderLogin() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n " + m.styles.Bold.Render("Sign in to "+m.serverURL) + "\n\n")
|
||||
if m.loginNote != "" {
|
||||
b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n")
|
||||
}
|
||||
b.WriteString(" " + m.styles.Header.Render("Username: ") + m.loginInputs[loginUsername].View() + "\n")
|
||||
b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\n")
|
||||
switch {
|
||||
case m.loggingIn:
|
||||
b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n")
|
||||
case m.loginErr != "":
|
||||
b.WriteString(m.styles.Error.Render(" "+m.loginErr) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// activeTeamLabel names what the lists are narrowed to.
|
||||
func (m Model) activeTeamLabel() string {
|
||||
if t, ok := m.activeTeam(); ok {
|
||||
@@ -56,6 +75,9 @@ func (m Model) renderTabs() string {
|
||||
}
|
||||
|
||||
func (m Model) renderBody() string {
|
||||
if m.mode == modeLogin {
|
||||
return m.renderLogin()
|
||||
}
|
||||
if m.err != nil {
|
||||
return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
|
||||
"\n" + m.styles.Muted.Render(" Press r to retry.")
|
||||
@@ -160,10 +182,13 @@ func (m Model) renderFooter() string {
|
||||
case modePasswordSet:
|
||||
return withStatus(" tab·next field enter·set password esc·cancel")
|
||||
|
||||
case modeLogin:
|
||||
return "\n" + m.styles.Footer.Render(" tab·next field enter·sign in esc·quit")
|
||||
|
||||
default:
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
|
||||
return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section L·sign out q·quit")
|
||||
case sectionAlerts:
|
||||
return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
|
||||
case sectionStats:
|
||||
@@ -173,7 +198,7 @@ func (m Model) renderFooter() string {
|
||||
case sectionSchedule:
|
||||
return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit")
|
||||
case sectionUsers:
|
||||
return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh tab·section q·quit")
|
||||
return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh L·sign out q·quit")
|
||||
}
|
||||
return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user