3 Commits

Author SHA1 Message Date
Niklas Ye e0c5a5cba3 Set a user's web UI password from the Users section
CI / test (push) Successful in 21s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 24s
terdut-server v0.10.2 serves a web UI you sign in to with a password,
and every user starts without one. Until now the only way to give
somebody their first password was a curl call with an API key. p in
Users sets the selected user's password.

The form asks for the current password only in the one case the server
checks it: you are changing your own password and already have one. The
client has no other way to know who its key belongs to, so opening the
form calls GET /api/me first and shows the fields once that answers.
Setting someone else's password sends no current_password at all,
rather than an empty one.

Length (at least 10) and the repeated entry are checked before anything
is sent, mirroring the server's rule so a typo costs no round trip. The
server stays authoritative: a wrong current password comes back as its
own 403 message on the dashboard. The status line says the user's other
web sessions were signed out, because the server does that on every
password change. API keys are not affected.

Older servers have no /api/me. The client now returns a typed
StatusError carrying the status code, so a 404 there reads as "needs
terdut-server v0.10.2 or later" rather than a bare "server returned
404". Its Error() text is unchanged, so every existing message reads as
before.

Requires terdut-server v0.10.2 only for this form. Everything else works
against the same servers as before.
2026-09-19 21:09:56 +02:00
Niklas Ye 0006424eaf Act on the selected row, not the one the table moved to
In Users, pressing k on a user opened API keys for the user above it.
The dashboard hands every key to the section's table before the
section's own handler reads the cursor, and bubbles' table claims
several letters for navigation: k is up, d half a page down, f a page
down. A letter that is also an action therefore moved the cursor first,
and the action landed on the row it had moved to. With your own user
first in the list, k looked like it only ever showed your own keys.

The same collision hit two destructive keys:

- d in Users asked to delete a user half a page below the selected one.
  The confirmation names the user, and that was the only thing standing
  between a keypress and deleting the wrong person.
- d in Schedule targeted a different day's assignment the same way.

f in Incidents and Alerts cycled the filter and paged the cursor down
too, which was harmless but wrong.

Each table now gives up exactly the letters its section acts on,
through tableKeyMap. The arrow keys and every other default binding are
untouched. The cost is that k no longer moves up in Users, where it
means API keys, as the README has always said. The up arrow still
works, and the README now says to use it there.

users_test.go reproduces all four. With tableKeyMap reverted to the
defaults, each of them fails exactly as reported.
2026-09-19 21:09:06 +02:00
niklas 9a510ecc77 Merge pull request 'Colour themes, defaulting to gruvbox dark' (#1) from color-themes into main
CI / test (push) Successful in 2s
Release / test (push) Successful in 2s
Release / binaries (push) Successful in 9s
Reviewed-on: #1
2026-08-20 09:11:13 +00:00
8 changed files with 549 additions and 11 deletions
+5
View File
@@ -193,3 +193,8 @@ Users section:
| `t` | Edit the user's ntfy topic — submit empty to clear it | | `t` | Edit the user's ntfy topic — submit empty to clear it |
| `d` | Delete a user | | `d` | Delete a user |
| `k` | API keys for the selected user | | `k` | API keys for the selected user |
| `p` | Set the selected user's web UI password — asks for the current one when it is your own |
In Users, `k` and `d` act on the selected row, so move with `↑`/`↓` there rather
than `k`. Setting passwords needs terdut-server **v0.10.2 or later**, the first
with a web UI.
+40 -4
View File
@@ -37,6 +37,20 @@ func (c *Client) newRequest(method, path string) (*http.Request, error) {
return req, nil return req, nil
} }
// StatusError is a response the server answered with a 4xx or 5xx. Message is
// the server's own {"error": ...} text, empty when the body carried none.
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
if e.Message != "" {
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
}
return fmt.Sprintf("server returned %d", e.Code)
}
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 {
@@ -49,10 +63,7 @@ func (c *Client) do(req *http.Request, out any) error {
Error string `json:"error"` Error string `json:"error"`
} }
_ = json.NewDecoder(resp.Body).Decode(&e) _ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" { return &StatusError{Code: resp.StatusCode, Message: e.Error}
return fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return fmt.Errorf("server returned %d", resp.StatusCode)
} }
if out != nil { if out != nil {
@@ -452,6 +463,31 @@ func (c *Client) DeleteAPIKey(userID, keyID int64) error {
return c.do(req, nil) return c.do(req, nil)
} }
// Me returns the user the API key belongs to, and whether they have a web UI
// password. Needs terdut-server v0.10.2 or later; older servers answer 404.
func (c *Client) Me() (*Me, error) {
req, err := c.newRequest(http.MethodGet, "/api/me")
if err != nil {
return nil, err
}
var me Me
return &me, c.do(req, &me)
}
// SetPassword sets a user's web UI password. current is only checked by the
// server when a user changes their own existing password; pass "" otherwise.
func (c *Client) SetPassword(userID int64, password, current string) error {
body := struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password,omitempty"`
}{Password: password, CurrentPassword: current}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/password", userID), body)
if err != nil {
return err
}
return c.do(req, nil)
}
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway). // HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway).
func (c *Client) HealthCheck() error { func (c *Client) HealthCheck() error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil) req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
+50
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -363,3 +364,52 @@ func TestIncidentStats_NullAveragesStayNil(t *testing.T) {
t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds) t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds)
} }
} }
func TestClient_Me(t *testing.T) {
c, got := stub(t, http.StatusOK, `{"user":{"id":3,"username":"erik"},"has_password":true}`)
me, err := c.Me()
if err != nil {
t.Fatalf("me: %v", err)
}
if got.method != http.MethodGet || got.path != "/api/me" {
t.Errorf("expected GET /api/me, got %s %s", got.method, got.path)
}
if me.User.ID != 3 || !me.HasPassword {
t.Errorf("unexpected decode %+v", me)
}
}
func TestClient_SetPassword(t *testing.T) {
c, got := stub(t, http.StatusNoContent, ``)
if err := c.SetPassword(2, "a brand new secret", ""); err != nil {
t.Fatalf("set password: %v", err)
}
if got.method != http.MethodPut || got.path != "/api/users/2/password" {
t.Errorf("expected PUT /api/users/2/password, got %s %s", got.method, got.path)
}
// Setting someone else's password carries no current_password at all,
// rather than an empty one.
if got.body != `{"password":"a brand new secret"}` {
t.Errorf("unexpected body %s", got.body)
}
c, got = stub(t, http.StatusNoContent, ``)
c.SetPassword(1, "a brand new secret", "the old one")
if !strings.Contains(got.body, `"current_password":"the old one"`) {
t.Errorf("current password missing from %s", got.body)
}
}
// Older servers have no /api/me; the caller tells that apart by the status
// code, so the typed error has to carry it.
func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `404 page not found`)
_, err := c.Me()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("expected a 404 StatusError, got %v", err)
}
if err.Error() != "server returned 404" {
t.Errorf("message changed: %q", err.Error())
}
}
+6
View File
@@ -182,6 +182,12 @@ func (u User) Topic() string {
return *u.NtfyTopic return *u.NtfyTopic
} }
// Me is GET /api/me: the caller, and whether they can sign in to the web UI.
type Me struct {
User User `json:"user"`
HasPassword bool `json:"has_password"`
}
type APIKey struct { type APIKey struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
+99 -6
View File
@@ -1,12 +1,16 @@
package tui package tui
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"slices"
"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/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/table" "github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
@@ -45,8 +49,21 @@ const (
modeAPIKeyCreate modeAPIKeyCreate
modeAPIKeyReveal modeAPIKeyReveal
modeAPIKeyRevokeByID modeAPIKeyRevokeByID
modePasswordSet
) )
// Fields of the set-password form, in tab order.
const (
pwCurrent = iota
pwNew
pwRepeat
pwFieldCount
)
// minPasswordLen mirrors the server's rule, so a short password is refused
// here rather than after a round trip.
const minPasswordLen = 10
type confirmTarget int type confirmTarget int
const ( const (
@@ -136,6 +153,8 @@ type usersFetchedMsg struct{ users []api.User }
type apiKeyCreatedMsg struct{ key api.APIKey } type apiKeyCreatedMsg struct{ key api.APIKey }
type apiKeyRevokedMsg struct{} type apiKeyRevokedMsg struct{}
type userActionErrMsg struct{ err error } type userActionErrMsg struct{ err error }
type meFetchedMsg struct{ me api.Me }
type passwordSetMsg struct{ username string }
// ── Model ────────────────────────────────────────────────────────────────── // ── Model ──────────────────────────────────────────────────────────────────
@@ -244,6 +263,15 @@ type Model struct {
apiKeyRevokeInput textinput.Model apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey revealedAPIKey api.APIKey
// Set-password form. The current-password field is shown only when the
// target is the key's own user and already has a password, which is the
// one case the server asks for it; pwLoading covers the /api/me lookup
// that decides it.
pwInputs [pwFieldCount]textinput.Model
pwFocus int
pwNeedCurrent bool
pwLoading bool
help help.Model help help.Model
keys keyMap keys keyMap
styles Styles styles Styles
@@ -253,22 +281,26 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
st := newStyles(th) st := newStyles(th)
ts := st.Table() ts := st.Table()
incidentT := table.New(table.WithFocused(true)) // Each table sees a key before the section's own handler does, so any
// key a section uses as an action must be taken out of that table's
// navigation bindings, or the cursor moves first and the action lands on
// a different row. See tableKeyMap.
incidentT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
incidentT.SetStyles(ts) incidentT.SetStyles(ts)
alertT := table.New(table.WithFocused(true)) alertT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
alertT.SetStyles(ts) alertT.SetStyles(ts)
archivedT := table.New(table.WithFocused(true)) archivedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
archivedT.SetStyles(ts) archivedT.SetStyles(ts)
schedT := table.New(table.WithFocused(true)) schedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d")))
schedT.SetStyles(ts) schedT.SetStyles(ts)
pickerT := table.New(table.WithFocused(true)) pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
pickerT.SetStyles(ts) pickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true)) manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
manageT.SetStyles(ts) manageT.SetStyles(ts)
// Sized by the first tea.WindowSizeMsg; built here so it carries the default // Sized by the first tea.WindowSizeMsg; built here so it carries the default
@@ -303,11 +335,23 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
revokeIn.Placeholder = "integer key ID" revokeIn.Placeholder = "integer key ID"
revokeIn.CharLimit = 20 revokeIn.CharLimit = 20
var pwIn [pwFieldCount]textinput.Model
for i, placeholder := range [pwFieldCount]string{"current password", "new password (min. 10 characters)", "repeat new password"} {
pwIn[i] = textinput.New()
pwIn[i].Placeholder = placeholder
pwIn[i].EchoMode = textinput.EchoPassword
pwIn[i].EchoCharacter = '•'
pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer
}
for _, in := range []*textinput.Model{ for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, &noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} { } {
*in = st.Input(*in) *in = st.Input(*in)
} }
for i := range pwIn {
pwIn[i] = st.Input(pwIn[i])
}
helpModel := help.New() helpModel := help.New()
helpModel.Styles = st.Help() helpModel.Styles = st.Help()
@@ -344,6 +388,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
ntfyTopicInput: topicIn, ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn, apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn, apiKeyRevokeInput: revokeIn,
pwInputs: pwIn,
help: helpModel, help: helpModel,
keys: keys, keys: keys,
styles: st, styles: st,
@@ -356,6 +401,31 @@ func (m Model) Init() tea.Cmd {
// ── Table rebuilders ─────────────────────────────────────────────────────── // ── Table rebuilders ───────────────────────────────────────────────────────
// tableKeyMap is the bubbles table keymap without the given keys.
//
// The table's defaults claim several letters -- k up, d half a page down, f a
// page down -- and the dashboard hands every key to the table before the
// section's own handler reads the cursor. A letter that is both, like k for
// API keys in Users, therefore moved the cursor and then acted on the row it
// had moved to. Each table gives up the letters its section acts on; the
// arrow keys and the rest of the defaults are untouched.
func tableKeyMap(reserved ...string) table.KeyMap {
km := table.DefaultKeyMap()
for _, b := range []*key.Binding{
&km.LineUp, &km.LineDown, &km.PageUp, &km.PageDown,
&km.HalfPageUp, &km.HalfPageDown, &km.GotoTop, &km.GotoBottom,
} {
var keep []string
for _, k := range b.Keys() {
if !slices.Contains(reserved, k) {
keep = append(keep, k)
}
}
b.SetKeys(keep...)
}
return km
}
// setRows replaces a table's rows and keeps its cursor in a state the rest of // setRows replaces a table's rows and keeps its cursor in a state the rest of
// this package can rely on: valid whenever the table has any rows at all. // this package can rely on: valid whenever the table has any rows at all.
// //
@@ -996,6 +1066,29 @@ func createAPIKeyCmd(client *api.Client, userID int64, name string) tea.Cmd {
} }
} }
func fetchMeCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
me, err := client.Me()
var se *api.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
return userActionErrMsg{errors.New("this server has no passwords -- needs terdut-server v0.10.2 or later")}
}
if err != nil {
return userActionErrMsg{err}
}
return meFetchedMsg{me: *me}
}
}
func setPasswordCmd(client *api.Client, user api.User, password, current string) tea.Cmd {
return func() tea.Msg {
if err := client.SetPassword(user.ID, password, current); err != nil {
return userActionErrMsg{err}
}
return passwordSetMsg{username: user.Username}
}
}
func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd { func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.DeleteAPIKey(userID, keyID); err != nil { if err := client.DeleteAPIKey(userID, keyID); err != nil {
+117
View File
@@ -1,6 +1,8 @@
package tui package tui
import ( import (
"fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
@@ -167,8 +169,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.mode = modeDashboard m.mode = modeDashboard
return m, clearStatusCmd() return m, clearStatusCmd()
case meFetchedMsg:
if m.mode != modePasswordSet {
return m, nil // the form was closed before the lookup came back
}
m.pwLoading = false
m.pwNeedCurrent = msg.me.User.ID == m.selectedUser.ID && msg.me.HasPassword
m.pwFocus = pwNew
if m.pwNeedCurrent {
m.pwFocus = pwCurrent
}
m.pwInputs[m.pwFocus].Focus()
return m, nil
case passwordSetMsg:
m.statusMsg = "password set for " + msg.username + " -- their other web sessions were signed out"
return m, clearStatusCmd()
case userActionErrMsg: case userActionErrMsg:
m.usersLoading = false m.usersLoading = false
m.pwLoading = false
m.blurPasswordForm()
m.statusMsg = "error: " + msg.err.Error() m.statusMsg = "error: " + msg.err.Error()
m.mode = modeDashboard m.mode = modeDashboard
return m, clearStatusCmd() return m, clearStatusCmd()
@@ -279,6 +300,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 modePasswordSet:
var inputCmd tea.Cmd
if !m.pwLoading {
m.pwInputs[m.pwFocus], inputCmd = m.pwInputs[m.pwFocus].Update(msg)
}
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
default: // modeDashboard default: // modeDashboard
if m.connected { if m.connected {
switch m.activeSection { switch m.activeSection {
@@ -338,6 +367,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleUserNotifyEditKey(msg) return m.handleUserNotifyEditKey(msg)
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg) return m.handleAPIKeyMenuKey(msg)
case modePasswordSet:
return m.handlePasswordKey(msg)
case modeAPIKeyCreate: case modeAPIKeyCreate:
return m.handleAPIKeyCreateKey(msg) return m.handleAPIKeyCreateKey(msg)
case modeAPIKeyReveal: case modeAPIKeyReveal:
@@ -533,6 +564,26 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
m.mode = modeAPIKeyMenu m.mode = modeAPIKeyMenu
return m, nil return m, nil
case "p":
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
return m, nil
}
cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) {
return m, nil
}
m.selectedUser = m.users[cursor]
for i := range m.pwInputs {
m.pwInputs[i].Reset()
m.pwInputs[i].Blur()
}
m.pwNeedCurrent = false
m.pwLoading = true
m.mode = modePasswordSet
// Whether the form needs the current password depends on who the key
// belongs to, which the client does not otherwise know.
return m, fetchMeCmd(m.client)
} }
return m, nil return m, nil
@@ -1127,3 +1178,69 @@ func (m Model) handleAPIKeyRevokeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// ── Set password ──────────────────────────────────────────────────────────────
// pwFields is the set-password form's fields in tab order.
func (m Model) pwFields() []int {
if m.pwNeedCurrent {
return []int{pwCurrent, pwNew, pwRepeat}
}
return []int{pwNew, pwRepeat}
}
func (m *Model) blurPasswordForm() {
for i := range m.pwInputs {
m.pwInputs[i].Blur()
}
}
func (m Model) handlePasswordKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.blurPasswordForm()
m.pwLoading = false
m.mode = modeDashboard
return m, nil
}
if m.pwLoading {
return m, nil
}
switch msg.String() {
case "tab", "shift+tab":
fields := m.pwFields()
i := slices.Index(fields, m.pwFocus)
step := 1
if msg.String() == "shift+tab" {
step = len(fields) - 1
}
m.pwInputs[m.pwFocus].Blur()
m.pwFocus = fields[(i+step)%len(fields)]
m.pwInputs[m.pwFocus].Focus()
return m, nil
case "enter":
password := m.pwInputs[pwNew].Value()
switch {
case m.pwNeedCurrent && m.pwInputs[pwCurrent].Value() == "":
m.statusMsg = "enter your current password"
return m, clearStatusCmd()
case len(password) < minPasswordLen:
m.statusMsg = fmt.Sprintf("the password must be at least %d characters", minPasswordLen)
return m, clearStatusCmd()
case password != m.pwInputs[pwRepeat].Value():
m.statusMsg = "the two new passwords do not match"
return m, clearStatusCmd()
}
current := ""
if m.pwNeedCurrent {
current = m.pwInputs[pwCurrent].Value()
}
m.blurPasswordForm()
m.mode = modeDashboard
m.statusMsg = "Setting password…"
return m, setPasswordCmd(m.client, m.selectedUser, password, current)
}
return m, nil
}
+204
View File
@@ -0,0 +1,204 @@
package tui
import (
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
tea "github.com/charmbracelet/bubbletea"
)
// threeUsers is the Users section with the cursor on the last of three users.
func threeUsers() Model {
m := onUsers([]api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "anna"},
{ID: 3, Username: "erik"},
})
m.userManageTable.SetCursor(2)
return m
}
// The table used to see k before the section did, take it as "up", and the
// handler then opened API keys for the user above the one selected.
func TestUsers_APIKeysOpenForTheSelectedUser(t *testing.T) {
m, _ := press(t, threeUsers(), "k")
if m.mode != modeAPIKeyMenu {
t.Fatalf("expected the API key menu, got mode %v", m.mode)
}
if m.selectedUser.Username != "erik" {
t.Errorf("API keys opened for %s, want erik", m.selectedUser.Username)
}
}
// Same collision with d, which the table read as half a page down: the delete
// confirmation named a different user than the one under the cursor.
func TestUsers_DeleteTargetsTheSelectedUser(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "d")
if m.mode != modeConfirm || m.selectedUser.Username != "niklas" {
t.Errorf("delete asked about %q in mode %v, want niklas", m.selectedUser.Username, m.mode)
}
}
func TestSchedule_DeleteTargetsTheSelectedDay(t *testing.T) {
m := sized()
m.activeSection = sectionSchedule
m.scheduleEntries = []api.ScheduleEntry{
{ID: 10, UserID: 1, Username: "niklas", Date: m.scheduleWindow.Format("2006-01-02")},
{ID: 11, UserID: 2, Username: "anna", Date: m.scheduleWindow.AddDate(0, 0, 1).Format("2006-01-02")},
}
m.scheduleDays = buildScheduleDays(m.scheduleWindow, m.scheduleEntries)
m.rebuildScheduleTable()
m.scheduleTable.SetCursor(0)
m, _ = press(t, m, "d")
if m.pendingDeleteEntry == nil || m.pendingDeleteEntry.ID != 10 {
t.Errorf("schedule delete targeted %+v, want entry 10", m.pendingDeleteEntry)
}
}
// f cycles the filter; it must not also page the cursor down.
func TestFilter_DoesNotMoveTheCursor(t *testing.T) {
m := sized()
m.incidents = make([]api.Incident, 40)
for i := range m.incidents {
m.incidents[i] = api.Incident{ID: int64(i + 1), Title: "x", Status: api.StatusTriggered, TriggeredAt: time.Now()}
}
m.rebuildIncidentTable()
m, _ = press(t, m, "f")
if c := m.incidentTable.Cursor(); c != 0 {
t.Errorf("f moved the cursor to %d", c)
}
}
// The arrow keys still move the users table, now that k is an action there.
func TestUsers_ArrowKeysStillNavigate(t *testing.T) {
m := threeUsers()
next, _ := m.Update(keyUp())
if c := next.(Model).userManageTable.Cursor(); c != 1 {
t.Errorf("up arrow left the cursor on %d, want 1", c)
}
}
func TestPassword_OpensForTheSelectedUserAndLooksUpWhoIAm(t *testing.T) {
m, cmd := press(t, threeUsers(), "p")
if m.mode != modePasswordSet || m.selectedUser.Username != "erik" {
t.Fatalf("expected the password form for erik, got mode %v for %q", m.mode, m.selectedUser.Username)
}
if !m.pwLoading || cmd == nil {
t.Error("the form should look up /api/me before it is usable")
}
if !strings.Contains(m.View(), "Checking who this key belongs to") {
t.Error("the form should say it is waiting")
}
}
func TestPassword_SomeoneElseNeedsNoCurrentPassword(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
m = next.(Model)
if m.pwNeedCurrent || m.pwFocus != pwNew {
t.Errorf("setting erik's password as niklas should not ask for a current one")
}
if strings.Contains(m.View(), "Current password") {
t.Error("the current-password field should be hidden")
}
}
func TestPassword_OwnExistingPasswordNeedsCurrent(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
m = next.(Model)
if !m.pwNeedCurrent || m.pwFocus != pwCurrent {
t.Fatal("changing your own existing password should ask for the current one first")
}
m = typeInto(t, m, "correct horse")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "a brand new secret")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "a brand new secret")
m, cmd := press(t, m, "enter")
if cmd == nil || m.mode != modeDashboard {
t.Errorf("a complete form should submit (mode %v)", m.mode)
}
}
func TestPassword_OwnFirstPasswordNeedsNoCurrent(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: false}})
if next.(Model).pwNeedCurrent {
t.Error("there is no current password to ask for yet")
}
}
func TestPassword_RejectsBeforeSending(t *testing.T) {
cases := []struct{ name, pw, repeat, want string }{
{"too short", "short", "short", "at least 10"},
{"mismatch", "a brand new secret", "a different secret", "do not match"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}}})
m = typeInto(t, next.(Model), tc.pw)
m, _ = press(t, m, "tab")
m = typeInto(t, m, tc.repeat)
m, cmd := press(t, m, "enter")
if m.mode != modePasswordSet {
t.Error("the form should stay open")
}
if !strings.Contains(m.statusMsg, tc.want) {
t.Errorf("status %q should mention %q", m.statusMsg, tc.want)
}
if cmd == nil {
return
}
// Only the clear-status timer may be scheduled, never a request.
if _, ok := cmd().(clearStatusMsg); !ok {
t.Error("nothing should be sent to the server")
}
})
}
}
func TestPassword_EscapeCancels(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
m, _ = press(t, m, "esc")
if m.mode != modeDashboard {
t.Errorf("esc should close the form, got mode %v", m.mode)
}
// A lookup arriving after the form closed must not reopen anything.
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 3}, HasPassword: true}})
if next.(Model).mode != modeDashboard {
t.Error("a late /api/me answer reopened the form")
}
}
func TestPassword_ErrorClosesTheFormWithAMessage(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(userActionErrMsg{errTest("server returned 403: current password is incorrect")})
m = next.(Model)
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "current password is incorrect") {
t.Errorf("expected the server's message on the dashboard, got %q in mode %v", m.statusMsg, m.mode)
}
}
func typeInto(t *testing.T, m Model, s string) Model {
t.Helper()
next, _ := m.Update(runes(s))
return next.(Model)
}
func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} }
func keyUp() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyUp} }
type errTest string
func (e errTest) Error() string { return string(e) }
+28 -1
View File
@@ -83,6 +83,8 @@ func (m Model) renderBody() string {
return m.renderAPIKeyReveal() return m.renderAPIKeyReveal()
case modeAPIKeyRevokeByID: case modeAPIKeyRevokeByID:
return m.renderAPIKeyRevokeByID() return m.renderAPIKeyRevokeByID()
case modePasswordSet:
return m.renderPasswordSet()
default: default:
return m.renderDashboard() return m.renderDashboard()
} }
@@ -144,6 +146,9 @@ func (m Model) renderFooter() string {
case modeAPIKeyRevokeByID: case modeAPIKeyRevokeByID:
return withStatus(" enter·revoke esc·back") return withStatus(" enter·revoke esc·back")
case modePasswordSet:
return withStatus(" tab·next field enter·set password esc·cancel")
default: default:
switch m.activeSection { switch m.activeSection {
case sectionIncidents: case sectionIncidents:
@@ -157,7 +162,7 @@ func (m Model) renderFooter() string {
case sectionSchedule: case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
case sectionUsers: case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit") return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh tab·section 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()))
} }
@@ -810,6 +815,28 @@ func (m Model) renderUserNotifyEdit() string {
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
} }
func (m Model) renderPasswordSet() string {
header := fmt.Sprintf("\n Web UI password for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
if m.pwLoading {
return header + line(m.styles.Muted, " Checking who this key belongs to…")
}
labels := [pwFieldCount]string{
pwCurrent: " Current password: ",
pwNew: " New password: ",
pwRepeat: " Repeat: ",
}
var form string
for _, f := range m.pwFields() {
label := labels[f]
if f == m.pwFocus {
label = m.styles.Selected.Render(label)
}
form += label + m.pwInputs[f].View() + "\n"
}
hint := fmt.Sprintf(" At least %d characters. Setting it signs %s out of every other\n web UI session. API keys are not affected.", minPasswordLen, m.selectedUser.Username)
return header + form + "\n" + line(m.styles.Muted, hint)
}
func (m Model) renderAPIKeyMenu() string { func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.") warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")