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
+40 -4
View File
@@ -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)
+50
View File
@@ -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())
}
}
+6
View File
@@ -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"`