Set a user's web UI password from the Users section
CI / test (push) Successful in 21s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 24s

terdut-server v0.10.2 serves a web UI you sign in to with a password,
and every user starts without one. Until now the only way to give
somebody their first password was a curl call with an API key. p in
Users sets the selected user's password.

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

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

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

Requires terdut-server v0.10.2 only for this form. Everything else works
against the same servers as before.
This commit is contained in:
Niklas Ye
2026-09-19 20:57:00 +02:00
parent 0006424eaf
commit e0c5a5cba3
8 changed files with 427 additions and 7 deletions
+63 -1
View File
@@ -1,7 +1,9 @@
package tui
import (
"errors"
"fmt"
"net/http"
"slices"
"time"
@@ -47,8 +49,21 @@ const (
modeAPIKeyCreate
modeAPIKeyReveal
modeAPIKeyRevokeByID
modePasswordSet
)
// Fields of the set-password form, in tab order.
const (
pwCurrent = iota
pwNew
pwRepeat
pwFieldCount
)
// minPasswordLen mirrors the server's rule, so a short password is refused
// here rather than after a round trip.
const minPasswordLen = 10
type confirmTarget int
const (
@@ -138,6 +153,8 @@ type usersFetchedMsg struct{ users []api.User }
type apiKeyCreatedMsg struct{ key api.APIKey }
type apiKeyRevokedMsg struct{}
type userActionErrMsg struct{ err error }
type meFetchedMsg struct{ me api.Me }
type passwordSetMsg struct{ username string }
// ── Model ──────────────────────────────────────────────────────────────────
@@ -246,6 +263,15 @@ type Model struct {
apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey
// Set-password form. The current-password field is shown only when the
// target is the key's own user and already has a password, which is the
// one case the server asks for it; pwLoading covers the /api/me lookup
// that decides it.
pwInputs [pwFieldCount]textinput.Model
pwFocus int
pwNeedCurrent bool
pwLoading bool
help help.Model
keys keyMap
styles Styles
@@ -274,7 +300,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
pickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k")))
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
manageT.SetStyles(ts)
// Sized by the first tea.WindowSizeMsg; built here so it carries the default
@@ -309,11 +335,23 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
revokeIn.Placeholder = "integer key ID"
revokeIn.CharLimit = 20
var pwIn [pwFieldCount]textinput.Model
for i, placeholder := range [pwFieldCount]string{"current password", "new password (min. 10 characters)", "repeat new password"} {
pwIn[i] = textinput.New()
pwIn[i].Placeholder = placeholder
pwIn[i].EchoMode = textinput.EchoPassword
pwIn[i].EchoCharacter = '•'
pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer
}
for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
for i := range pwIn {
pwIn[i] = st.Input(pwIn[i])
}
helpModel := help.New()
helpModel.Styles = st.Help()
@@ -350,6 +388,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn,
pwInputs: pwIn,
help: helpModel,
keys: keys,
styles: st,
@@ -1027,6 +1066,29 @@ func createAPIKeyCmd(client *api.Client, userID int64, name string) tea.Cmd {
}
}
func fetchMeCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
me, err := client.Me()
var se *api.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
return userActionErrMsg{errors.New("this server has no passwords -- needs terdut-server v0.10.2 or later")}
}
if err != nil {
return userActionErrMsg{err}
}
return meFetchedMsg{me: *me}
}
}
func setPasswordCmd(client *api.Client, user api.User, password, current string) tea.Cmd {
return func() tea.Msg {
if err := client.SetPassword(user.ID, password, current); err != nil {
return userActionErrMsg{err}
}
return passwordSetMsg{username: user.Username}
}
}
func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteAPIKey(userID, keyID); err != nil {