Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c5a5cba3 | |||
| 0006424eaf | |||
| 9a510ecc77 |
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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{
|
||||||
¬eIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
|
¬eIn, &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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user