diff --git a/README.md b/README.md index cbbb63d..36d119b 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,8 @@ Users section: | `t` | Edit the user's ntfy topic — submit empty to clear it | | `d` | Delete a 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`. +than `k`. Setting passwords needs terdut-server **v0.10.2 or later**, the first +with a web UI. diff --git a/internal/api/client.go b/internal/api/client.go index 194d880..38993d7 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -37,6 +37,20 @@ func (c *Client) newRequest(method, path string) (*http.Request, error) { 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 { resp, err := c.httpClient.Do(req) if err != nil { @@ -49,10 +63,7 @@ func (c *Client) do(req *http.Request, out any) error { Error string `json:"error"` } _ = json.NewDecoder(resp.Body).Decode(&e) - if e.Error != "" { - return fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error) - } - return fmt.Errorf("server returned %d", resp.StatusCode) + return &StatusError{Code: resp.StatusCode, Message: e.Error} } if out != nil { @@ -452,6 +463,31 @@ func (c *Client) DeleteAPIKey(userID, keyID int64) error { 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). func (c *Client) HealthCheck() error { req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil) diff --git a/internal/api/client_test.go b/internal/api/client_test.go index b0ba422..5f3727b 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "io" "net/http" "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) } } + +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()) + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 54d6acf..563ab3e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -182,6 +182,12 @@ func (u User) Topic() string { 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 { ID int64 `json:"id"` UserID int64 `json:"user_id"` diff --git a/internal/tui/model.go b/internal/tui/model.go index 2b63afb..c29a75b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,7 +1,9 @@ package tui import ( + "errors" "fmt" + "net/http" "slices" "time" @@ -47,8 +49,21 @@ const ( modeAPIKeyCreate modeAPIKeyReveal 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 const ( @@ -138,6 +153,8 @@ type usersFetchedMsg struct{ users []api.User } type apiKeyCreatedMsg struct{ key api.APIKey } type apiKeyRevokedMsg struct{} type userActionErrMsg struct{ err error } +type meFetchedMsg struct{ me api.Me } +type passwordSetMsg struct{ username string } // ── Model ────────────────────────────────────────────────────────────────── @@ -246,6 +263,15 @@ type Model struct { apiKeyRevokeInput textinput.Model 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 keys keyMap styles Styles @@ -274,7 +300,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap())) pickerT.SetStyles(ts) - manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k"))) + manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p"))) manageT.SetStyles(ts) // Sized by the first tea.WindowSizeMsg; built here so it carries the default @@ -309,11 +335,23 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio revokeIn.Placeholder = "integer key ID" 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{ ¬eIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, } { *in = st.Input(*in) } + for i := range pwIn { + pwIn[i] = st.Input(pwIn[i]) + } helpModel := help.New() helpModel.Styles = st.Help() @@ -350,6 +388,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio ntfyTopicInput: topicIn, apiKeyNameInput: keyNameIn, apiKeyRevokeInput: revokeIn, + pwInputs: pwIn, help: helpModel, keys: keys, styles: st, @@ -1027,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 { return func() tea.Msg { if err := client.DeleteAPIKey(userID, keyID); err != nil { diff --git a/internal/tui/update.go b/internal/tui/update.go index 1c38aba..86a8bb7 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,6 +1,8 @@ package tui import ( + "fmt" + "slices" "strconv" "strings" @@ -167,8 +169,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mode = modeDashboard 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: m.usersLoading = false + m.pwLoading = false + m.blurPasswordForm() m.statusMsg = "error: " + msg.err.Error() m.mode = modeDashboard return m, clearStatusCmd() @@ -279,6 +300,14 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { case modeAPIKeyMenu, modeAPIKeyReveal: 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 if m.connected { switch m.activeSection { @@ -338,6 +367,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m.handleUserNotifyEditKey(msg) case modeAPIKeyMenu: return m.handleAPIKeyMenuKey(msg) + case modePasswordSet: + return m.handlePasswordKey(msg) case modeAPIKeyCreate: return m.handleAPIKeyCreateKey(msg) case modeAPIKeyReveal: @@ -533,6 +564,26 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.selectedUser = m.users[cursor] m.mode = modeAPIKeyMenu 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 @@ -1127,3 +1178,69 @@ func (m Model) handleAPIKeyRevokeKey(msg tea.KeyMsg) (Model, tea.Cmd) { 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 +} diff --git a/internal/tui/users_test.go b/internal/tui/users_test.go index d21fa73..06c075d 100644 --- a/internal/tui/users_test.go +++ b/internal/tui/users_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" "time" @@ -81,4 +82,123 @@ func TestUsers_ArrowKeysStillNavigate(t *testing.T) { } } +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) } diff --git a/internal/tui/view.go b/internal/tui/view.go index 48f214a..2b3a64f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -83,6 +83,8 @@ func (m Model) renderBody() string { return m.renderAPIKeyReveal() case modeAPIKeyRevokeByID: return m.renderAPIKeyRevokeByID() + case modePasswordSet: + return m.renderPasswordSet() default: return m.renderDashboard() } @@ -144,6 +146,9 @@ func (m Model) renderFooter() string { case modeAPIKeyRevokeByID: return withStatus(" enter·revoke esc·back") + case modePasswordSet: + return withStatus(" tab·next field enter·set password esc·cancel") + default: switch m.activeSection { case sectionIncidents: @@ -157,7 +162,7 @@ func (m Model) renderFooter() string { case sectionSchedule: return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") 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())) } @@ -810,6 +815,28 @@ func (m Model) renderUserNotifyEdit() string { 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 { 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.")