Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c5a5cba3 | |||
| 0006424eaf |
@@ -193,3 +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`. Setting passwords needs terdut-server **v0.10.2 or later**, the first
|
||||
with a web UI.
|
||||
|
||||
+40
-4
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+99
-6
@@ -1,12 +1,16 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
@@ -45,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 (
|
||||
@@ -136,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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -244,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
|
||||
@@ -253,22 +281,26 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
||||
st := newStyles(th)
|
||||
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)
|
||||
|
||||
alertT := table.New(table.WithFocused(true))
|
||||
alertT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
|
||||
alertT.SetStyles(ts)
|
||||
|
||||
archivedT := table.New(table.WithFocused(true))
|
||||
archivedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
||||
archivedT.SetStyles(ts)
|
||||
|
||||
schedT := table.New(table.WithFocused(true))
|
||||
schedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d")))
|
||||
schedT.SetStyles(ts)
|
||||
|
||||
pickerT := table.New(table.WithFocused(true))
|
||||
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
||||
pickerT.SetStyles(ts)
|
||||
|
||||
manageT := table.New(table.WithFocused(true))
|
||||
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
|
||||
@@ -303,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()
|
||||
@@ -344,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,
|
||||
@@ -356,6 +401,31 @@ func (m Model) Init() tea.Cmd {
|
||||
|
||||
// ── 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
|
||||
// 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 {
|
||||
return func() tea.Msg {
|
||||
if err := client.DeleteAPIKey(userID, keyID); err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user