Set a user's web UI password from the Users section
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.
This commit is contained in:
@@ -193,6 +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
|
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.
|
||||||
|
|||||||
+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"`
|
||||||
|
|||||||
+63
-1
@@ -1,7 +1,9 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -47,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 (
|
||||||
@@ -138,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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -246,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
|
||||||
@@ -274,7 +300,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
|||||||
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
||||||
pickerT.SetStyles(ts)
|
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)
|
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
|
||||||
@@ -309,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()
|
||||||
@@ -350,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,
|
||||||
@@ -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 {
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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} }
|
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