From ec5df0449b22285616ddd8ea722c296ee8cbbfaa Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Mon, 12 Oct 2026 21:00:40 +0200 Subject: [PATCH] Manage notification channels instead of an ntfy topic Mirrors the server's channels API: add/remove channels per user from the Users section. Requires the matching server release. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN --- README.md | 30 ++++++---- internal/api/client.go | 31 ++++++---- internal/api/client_test.go | 44 +++++++-------- internal/api/types.go | 45 ++++++++++----- internal/tui/model.go | 61 +++++++++++++------- internal/tui/model_test.go | 32 ++++++----- internal/tui/update.go | 102 ++++++++++++++++++++++++++------- internal/tui/update_test.go | 110 ++++++++++++++++++++++++------------ internal/tui/view.go | 72 +++++++++++++++++------ internal/tui/view_test.go | 52 ++++++++++++++--- 10 files changed, 405 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index e213baa..7ec6d72 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea). - **Teams** — switch between your teams, or see all of them at once - **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries - **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day -- **User management** — add and remove users, manage API keys, set each user's ntfy topic +- **User management** — add and remove users, manage API keys, manage each user's notification channels > Requires terdut-server **v0.20.0 or later**. The server became team-scoped in > v0.12 and this client follows it; earlier servers answer 404 for `/api/teams` @@ -36,7 +36,7 @@ server refuses anybody else. Stats always cover all your teams; the server cannot narrow them to one. Administrators are the only users who can create or delete users, or act on -someone else's password, topic or API keys. Everyone can manage their own. +someone else's password, notification channels or API keys. Everyone can manage their own. ## Alerts and incidents @@ -58,17 +58,23 @@ Two behaviours worth knowing before you press a key: - **Snooze is the "not now" button.** It hides an incident from the default queue without closing it, and expires on its own. -## Push notifications +## Notifications -When the server is configured for ntfy, an incident that opens pages whoever is -on call. Each user has their own topic, shown as a column in the Users section -and edited with `t`. A user with no topic falls back to the server's shared -fallback topic, which carries **no Acknowledge button** — the topic is shared, so -a button on it would let any subscriber acknowledge as somebody else. +When the server has a notification provider configured (ntfy today), an incident +that opens pages whoever is on call, on every notification channel they have +switched on. A channel is a provider type and an address (an ntfy topic); each +user's kinds of channel are shown as a column in the Users section and managed +with `t`: `a` adds one as `type:address` (for example `ntfy:my-topic`), `r` +removes one by its ID. Addresses are only sent to their owner, so an +administrator sees the kind, not where it goes. Turning a channel off is done in +the web UI. A user with no channel falls back to the server's shared fallback, +which carries **no Acknowledge button** — the fallback is shared, so a button on +it would let any recipient acknowledge as somebody else. -Every delivery lands on the incident's timeline: `Notified (triggered)` -when ntfy accepted the page, and `Notification to failed` when it ran out -of retries. That second one is the one to look for when nobody's phone rang. +Every delivery lands on the incident's timeline: `Notified (triggered via +ntfy)` when the provider accepted the page, and `Notification to failed` +when it ran out of retries. That second one is the one to look for when nobody's +phone rang. ## Installation @@ -229,7 +235,7 @@ Users section: | Key | Action | |-----|--------| | `n` | Create a user | -| `t` | Edit the user's ntfy topic — submit empty to clear it | +| `t` | Manage the user's notification channels — `a` add (`type:address`), `r` remove by ID | | `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 | diff --git a/internal/api/client.go b/internal/api/client.go index f000c28..3213425 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -623,21 +623,32 @@ func (c *Client) CreateUser(username, email string) (*User, error) { return &user, c.do(req, &user) } -// SetUserNotifyTarget points a user's push notifications at an ntfy topic. +// AddChannel gives a user a notification channel: a provider type the server +// has configured ("ntfy") and an address that provider understands (a topic). // -// An empty topic clears it: the server stores NULL, and that user's incidents -// page the shared fallback topic instead — which carries no Acknowledge button, -// because anyone subscribed to it could otherwise acknowledge as somebody else. -func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) { +// With no channel a user's incidents page the shared fallback instead, which +// carries no Acknowledge button, because anyone subscribed to it could +// otherwise acknowledge as somebody else. +func (c *Client) AddChannel(userID int64, typ, address string) (*NotificationChannel, error) { body := struct { - NtfyTopic string `json:"ntfy_topic"` - }{NtfyTopic: topic} - req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body) + Type string `json:"type"` + Address string `json:"address"` + }{Type: typ, Address: address} + req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID), body) if err != nil { return nil, err } - var user User - return &user, c.do(req, &user) + var ch NotificationChannel + return &ch, c.do(req, &ch) +} + +// RemoveChannel deletes one of a user's notification channels. +func (c *Client) RemoveChannel(userID, channelID int64) error { + req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/channels/%d", userID, channelID)) + if err != nil { + return err + } + return c.do(req, nil) } func (c *Client) DeleteUser(id int64) error { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 6616fa8..92d3b4f 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -173,8 +173,10 @@ func TestClient_IncidentEndpoints(t *testing.T) { http.MethodDelete, "/api/incidents/7/notes/12", ""}, {"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err }, http.MethodGet, "/api/stats/incidents", ""}, - {"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err }, - http.MethodPut, "/api/users/7/notify", ""}, + {"add channel", func(c *Client) error { _, err := c.AddChannel(7, "ntfy", "t"); return err }, + http.MethodPost, "/api/users/7/channels", `{}`}, + {"remove channel", func(c *Client) error { return c.RemoveChannel(7, 2) }, + http.MethodDelete, "/api/users/7/channels/2", ""}, } for _, tt := range tests { @@ -284,40 +286,34 @@ func TestClient_RequestBodies(t *testing.T) { } }) - t.Run("set notify target", func(t *testing.T) { - c, got := stub(t, http.StatusOK, `{}`) - if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil { - t.Fatalf("set notify target: %v", err) + t.Run("add channel", func(t *testing.T) { + c, got := stub(t, http.StatusCreated, `{"id":4,"type":"ntfy","enabled":true}`) + ch, err := c.AddChannel(3, "ntfy", "terdut-niklas") + if err != nil { + t.Fatalf("add channel: %v", err) } - if got.body != `{"ntfy_topic":"terdut-niklas"}` { + if got.body != `{"type":"ntfy","address":"terdut-niklas"}` { t.Errorf("unexpected body %q", got.body) } - }) - - // Clearing has to put an explicit empty string on the wire: omitting the - // field would leave the topic untouched instead of removing it. - t.Run("clear notify target", func(t *testing.T) { - c, got := stub(t, http.StatusOK, `{}`) - if _, err := c.SetUserNotifyTarget(3, ""); err != nil { - t.Fatalf("clear notify target: %v", err) - } - if got.body != `{"ntfy_topic":""}` { - t.Errorf("expected an explicit empty topic, got %q", got.body) + if ch.ID != 4 || ch.Type != "ntfy" || !ch.Enabled { + t.Errorf("unexpected channel %+v", ch) } }) } -func TestUser_TopicFlattensNilAndEmpty(t *testing.T) { +func TestUser_ChannelSummaryNamesKindsNeverAddresses(t *testing.T) { var users []User if err := json.Unmarshal([]byte( - `[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""}, - {"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil { + `[{"id":1,"username":"a"}, + {"id":2,"username":"b","channels":[{"id":1,"type":"ntfy","enabled":true}]}, + {"id":3,"username":"c","channels":[{"id":2,"type":"ntfy","address":"secret","enabled":true}, + {"id":3,"type":"mail","enabled":false}]}]`), &users); err != nil { t.Fatalf("decode: %v", err) } - want := []string{"", "", "terdut-c"} + want := []string{"", "ntfy", "ntfy, mail (off)"} for i, u := range users { - if got := u.Topic(); got != want[i] { - t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got) + if got := u.ChannelSummary(); got != want[i] { + t.Errorf("user %d: expected %q, got %q", u.ID, want[i], got) } } } diff --git a/internal/api/types.go b/internal/api/types.go index a00da91..9835119 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -1,6 +1,9 @@ package api -import "time" +import ( + "strings" + "time" +) // Alert is the server's record of what Alertmanager said. It is read-only: // acknowledging, assigning, noting and resolving all happen on the Incident an @@ -147,7 +150,7 @@ const ( // Written by the server's notifier from the delivery result, not at enqueue. // Detail carries the notification kind ("triggered", "reminder", "resolved"), // and on a failure the reason after it. An absent user means the page went to - // the shared fallback topic rather than to a person. + // the shared fallback rather than to a person. EventNotified = "notified" EventNotifyFailed = "notify_failed" ) @@ -224,20 +227,36 @@ type User struct { // disabled user cannot sign in or use their keys. DisabledAt *time.Time `json:"disabled_at,omitempty"` - // NtfyTopic is where this user's push notifications go. Nil and empty mean - // the same thing — no topic of their own — because the server stores a blank - // string as NULL. Their incidents fall back to the server's shared fallback - // topic, which carries no Acknowledge button. - NtfyTopic *string `json:"ntfy_topic,omitempty"` + // Channels are where this user's notifications go: a provider type and an + // address each. Only filled in for the caller's own row and, without the + // addresses, for an administrator's view of others. A user with none has + // their incidents page the server's shared fallback, which carries no + // Acknowledge button. + Channels []NotificationChannel `json:"channels,omitempty"` } -// Topic reads the user's ntfy topic, flattening the nil and empty cases the -// server treats alike. -func (u User) Topic() string { - if u.NtfyTopic == nil { - return "" +// NotificationChannel is one way of reaching a user. +type NotificationChannel struct { + ID int64 `json:"id"` + Type string `json:"type"` + + // Address is blank unless the caller owns the channel: it can be a secret. + Address string `json:"address,omitempty"` + Enabled bool `json:"enabled"` +} + +// ChannelSummary lists the kinds of channel the user has, "ntfy, mail (off)", +// or "" for none. Never an address. +func (u User) ChannelSummary() string { + parts := make([]string, 0, len(u.Channels)) + for _, c := range u.Channels { + if c.Enabled { + parts = append(parts, c.Type) + } else { + parts = append(parts, c.Type+" (off)") + } } - return *u.NtfyTopic + return strings.Join(parts, ", ") } // IsDisabled reports whether the account has been disabled. diff --git a/internal/tui/model.go b/internal/tui/model.go index 764e67b..43aa420 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -48,7 +48,9 @@ const ( modeUserPicker modeTeamPicker modeUserCreate - modeUserNotifyEdit + modeChannelMenu + modeChannelAdd + modeChannelRemoveByID modeAPIKeyMenu modeAPIKeyCreate modeAPIKeyReveal @@ -356,7 +358,8 @@ type Model struct { selectedUser api.User userFormInputs [2]textinput.Model userFormFocus int - ntfyTopicInput textinput.Model + channelAddInput textinput.Model + channelRemoveIn textinput.Model apiKeyNameInput textinput.Model apiKeyRevokeInput textinput.Model revealedAPIKey api.APIKey @@ -424,9 +427,13 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio emailIn.Placeholder = "email" emailIn.CharLimit = 128 - topicIn := textinput.New() - topicIn.Placeholder = "ntfy topic — empty clears it" - topicIn.CharLimit = 128 + channelIn := textinput.New() + channelIn.Placeholder = "type:address, e.g. ntfy:my-topic" + channelIn.CharLimit = 160 + + channelRemoveIn := textinput.New() + channelRemoveIn.Placeholder = "integer channel ID" + channelRemoveIn.CharLimit = 20 keyNameIn := textinput.New() keyNameIn.Placeholder = "key name (e.g. laptop)" @@ -458,7 +465,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio } for _, in := range []*textinput.Model{ - ¬eIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, + ¬eIn, &snoozeIn, &usernameIn, &emailIn, &channelIn, &channelRemoveIn, &keyNameIn, &revokeIn, } { *in = st.Input(*in) } @@ -507,7 +514,8 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio teamPickerTable: teamPickerT, userManageTable: manageT, userFormInputs: [2]textinput.Model{usernameIn, emailIn}, - ntfyTopicInput: topicIn, + channelAddInput: channelIn, + channelRemoveIn: channelRemoveIn, apiKeyNameInput: keyNameIn, apiKeyRevokeInput: revokeIn, pwInputs: pwIn, @@ -668,11 +676,11 @@ func (m *Model) rebuildUserManageTable() { m.userManageTable.SetColumns(userManageColumns(m.width)) rows := make([]table.Row, len(m.users)) for i, u := range m.users { - topic := u.Topic() - if topic == "" { - topic = "—" + channels := u.ChannelSummary() + if channels == "" { + channels = "—" } - rows[i] = table.Row{u.Username, u.Email, topic, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")} + rows[i] = table.Row{u.Username, u.Email, channels, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")} } setRows(&m.userManageTable, rows) m.userManageTable.SetHeight(tableHeight(m.height, 10)) @@ -768,7 +776,7 @@ func (m Model) canEditSchedule(t api.Team) bool { } // canManageUser mirrors the server's self-or-admin rule for a user's password, -// ntfy topic and API keys. +// notification channels and API keys. func (m Model) canManageUser(u api.User) bool { return m.isAdmin || u.ID == m.meID } @@ -940,17 +948,17 @@ func teamPickerColumns(width int) []table.Column { func userManageColumns(width int) []table.Column { createdW := 12 usernameW := 25 - topicW := 22 + channelsW := 22 flagsW := 14 // 10 = bubbles' Padding(0, 1) on each of the five cells. - emailW := width - usernameW - topicW - flagsW - createdW - 10 + emailW := width - usernameW - channelsW - flagsW - createdW - 10 if emailW < 15 { emailW = 15 } return []table.Column{ {Title: "Username", Width: usernameW}, {Title: "Email", Width: emailW}, - {Title: "Ntfy Topic", Width: topicW}, + {Title: "Channels", Width: channelsW}, {Title: "Flags", Width: flagsW}, {Title: "Created", Width: createdW}, } @@ -1496,12 +1504,25 @@ func createUserCmd(client *api.Client, username, email string) tea.Cmd { } } -// setUserNotifyTargetCmd points a user's pages at a topic, or clears it when -// topic is empty. It re-lists afterwards so the table shows what the server -// stored rather than what was typed. -func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd { +// addChannelCmd gives a user a notification channel. It re-lists afterwards so +// the table shows what the server stored rather than what was typed. +func addChannelCmd(client *api.Client, userID int64, typ, address string) tea.Cmd { return func() tea.Msg { - if _, err := client.SetUserNotifyTarget(userID, topic); err != nil { + if _, err := client.AddChannel(userID, typ, address); err != nil { + return userActionErrMsg{err} + } + users, err := client.ListUsers() + if err != nil { + return userActionErrMsg{err} + } + return usersFetchedMsg{users: users} + } +} + +// removeChannelCmd deletes one of a user's channels and re-lists. +func removeChannelCmd(client *api.Client, userID, channelID int64) tea.Cmd { + return func() tea.Msg { + if err := client.RemoveChannel(userID, channelID); err != nil { return userActionErrMsg{err} } users, err := client.ListUsers() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 314f31a..c0ab1de 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" "time" @@ -196,29 +197,32 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) { } } -// A user with no ntfy topic gets no pages of their own — the row has to say so +// A user with no channel gets no pages of their own — the row has to say so // rather than leaving a blank that reads as "not loaded yet". -func TestUserManageRows_ShowMissingTopic(t *testing.T) { - topic := "terdut-niklas" - empty := "" +func TestUserManageRows_ShowMissingChannels(t *testing.T) { m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m.width, m.height = 120, 40 m.users = []api.User{ - {ID: 1, Username: "niklas", NtfyTopic: &topic}, + {ID: 1, Username: "niklas", Channels: []api.NotificationChannel{ + {ID: 1, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}}, {ID: 2, Username: "alex"}, - // The server stores a blank topic as NULL, but a stale client or an older - // server can still hand one back; it means the same thing. - {ID: 3, Username: "sam", NtfyTopic: &empty}, + {ID: 3, Username: "sam", Channels: []api.NotificationChannel{ + {ID: 2, Type: "ntfy", Enabled: false}}}, } m.rebuildUserManageTable() rows := m.userManageTable.Rows() - if rows[0][2] != "terdut-niklas" { - t.Errorf("expected the topic in the row, got %q", rows[0][2]) + if rows[0][2] != "ntfy" { + t.Errorf("expected the channel kind in the row, got %q", rows[0][2]) } - if rows[1][2] != "—" || rows[2][2] != "—" { - t.Errorf("expected an em dash for nil and empty topics, got %q and %q", - rows[1][2], rows[2][2]) + if strings.Contains(rows[0][2], "terdut-niklas") { + t.Errorf("the row must not carry an address, got %q", rows[0][2]) + } + if rows[1][2] != "—" { + t.Errorf("expected an em dash for a user with no channel, got %q", rows[1][2]) + } + if rows[2][2] != "ntfy (off)" { + t.Errorf("expected a switched-off channel to say so, got %q", rows[2][2]) } } @@ -228,7 +232,7 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) { func TestColumnWidthsFitTheTerminal(t *testing.T) { for _, width := range []int{100, 110, 140, 200} { // Five cells each, or six once a Team column is added. userManageColumns - // is five cells as well: username, email, topic, flags, created. + // is five cells as well: username, email, channels, flags, created. for name, tc := range map[string]struct { cols []table.Column cells int diff --git a/internal/tui/update.go b/internal/tui/update.go index 8d6edf9..9def0b2 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -413,12 +413,21 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(inputCmd, ourCmd) - case modeUserNotifyEdit: + case modeChannelAdd: var inputCmd tea.Cmd - m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg) + m.channelAddInput, inputCmd = m.channelAddInput.Update(msg) m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(inputCmd, ourCmd) + case modeChannelRemoveByID: + var inputCmd tea.Cmd + m.channelRemoveIn, inputCmd = m.channelRemoveIn.Update(msg) + m2, ourCmd := m.handleKey(msg) + return m2, tea.Batch(inputCmd, ourCmd) + + case modeChannelMenu: + return m.handleKey(msg) + case modeAPIKeyCreate: var inputCmd tea.Cmd m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg) @@ -507,8 +516,12 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m.handleTeamPickerKey(msg) case modeUserCreate: return m.handleUserCreateKey(msg) - case modeUserNotifyEdit: - return m.handleUserNotifyEditKey(msg) + case modeChannelMenu: + return m.handleChannelMenuKey(msg) + case modeChannelAdd: + return m.handleChannelAddKey(msg) + case modeChannelRemoveByID: + return m.handleChannelRemoveKey(msg) case modeAPIKeyMenu: return m.handleAPIKeyMenuKey(msg) case modePasswordSet: @@ -721,15 +734,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { } m.selectedUser = m.users[cursor] if !m.canManageUser(m.selectedUser) { - cmd := m.refuseUserAction("notification topic") + cmd := m.refuseUserAction("notification channels") return m, cmd } - // Prefilled with what they have, so editing a topic does not mean - // retyping it, and clearing one is a deliberate wipe. - m.ntfyTopicInput.SetValue(m.selectedUser.Topic()) - m.ntfyTopicInput.CursorEnd() - m.ntfyTopicInput.Focus() - m.mode = modeUserNotifyEdit + m.mode = modeChannelMenu return m, nil case "k": @@ -1369,23 +1377,77 @@ func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil } -// handleUserNotifyEditKey edits one user's ntfy topic. -// -// Unlike the other forms here, an empty value is not a mistake to reject: it is -// how a topic is cleared, which the server accepts and treats as NULL. -func (m Model) handleUserNotifyEditKey(msg tea.KeyMsg) (Model, tea.Cmd) { +// handleChannelMenuKey is the menu for one user's notification channels: add +// one, or remove one by its ID. The same shape as the API key menu. +func (m Model) handleChannelMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "esc": - m.ntfyTopicInput.Blur() m.mode = modeDashboard return m, nil + case "a": + m.channelAddInput.Reset() + m.channelAddInput.Focus() + m.mode = modeChannelAdd + return m, nil + + case "r": + m.channelRemoveIn.Reset() + m.channelRemoveIn.Focus() + m.mode = modeChannelRemoveByID + return m, nil + } + + return m, nil +} + +// parseChannelSpec splits "type:address" at the first colon. The address may +// itself contain colons (a URL-ish topic, say), the type never does. +func parseChannelSpec(spec string) (typ, address string, ok bool) { + typ, address, found := strings.Cut(strings.TrimSpace(spec), ":") + typ, address = strings.TrimSpace(typ), strings.TrimSpace(address) + return typ, address, found && typ != "" && address != "" +} + +func (m Model) handleChannelAddKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.channelAddInput.Blur() + m.mode = modeChannelMenu + return m, nil + case "enter": - topic := strings.TrimSpace(m.ntfyTopicInput.Value()) - m.ntfyTopicInput.Blur() + typ, address, ok := parseChannelSpec(m.channelAddInput.Value()) + if !ok { + m.statusMsg = "use type:address, e.g. ntfy:my-topic" + return m, clearStatusCmd() + } + m.channelAddInput.Blur() m.mode = modeDashboard m.usersLoading = true - return m, setUserNotifyTargetCmd(m.client, m.selectedUser.ID, topic) + return m, addChannelCmd(m.client, m.selectedUser.ID, typ, address) + } + + return m, nil +} + +func (m Model) handleChannelRemoveKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.channelRemoveIn.Blur() + m.mode = modeChannelMenu + return m, nil + + case "enter": + channelID, err := strconv.ParseInt(strings.TrimSpace(m.channelRemoveIn.Value()), 10, 64) + if err != nil || channelID <= 0 { + m.statusMsg = "invalid channel ID — must be a positive integer" + return m, clearStatusCmd() + } + m.channelRemoveIn.Blur() + m.mode = modeDashboard + m.usersLoading = true + return m, removeChannelCmd(m.client, m.selectedUser.ID, channelID) } return m, nil diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 6ab876d..70d47b3 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -416,7 +416,7 @@ func TestSchedule_ReassigningAPartlyTakenWeekAsksOnce(t *testing.T) { mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam") } -// ── Ntfy topic ──────────────────────────────────────────────────────────── +// ── Notification channels ───────────────────────────────────────────────── // onUsers puts the model in the Users section with a loaded table. func onUsers(users []api.User) Model { @@ -428,42 +428,96 @@ func onUsers(users []api.User) Model { } func userFixtures() []api.User { - topic := "terdut-niklas" return []api.User{ - {ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic}, + {ID: 1, Username: "niklas", Email: "niklas@example.com", + Channels: []api.NotificationChannel{{ID: 3, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}}, {ID: 2, Username: "alex", Email: "alex@example.com"}, } } -func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) { +func TestChannels_TOpensTheMenuForTheUserUnderTheCursor(t *testing.T) { m, _ := press(t, onUsers(userFixtures()), "t") - if m.mode != modeUserNotifyEdit { - t.Fatalf("expected the topic editor, got mode %v", m.mode) + if m.mode != modeChannelMenu { + t.Fatalf("expected the channel menu, got mode %v", m.mode) } if m.selectedUser.ID != 1 { t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID) } - // Prefilled, so editing a topic does not mean retyping it from scratch. - if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" { - t.Errorf("expected the current topic prefilled, got %q", got) - } } -// A user with no topic opens an empty field rather than the previous user's. -func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) { - m := onUsers(userFixtures()) - m, _ = press(t, m, "t") +func TestChannels_AddStartsEmptyEveryTime(t *testing.T) { + m, _ := press(t, onUsers(userFixtures()), "t") + m, _ = press(t, m, "a") + if m.mode != modeChannelAdd { + t.Fatalf("expected the add form, got mode %v", m.mode) + } + m = typeInto(t, m, "ntfy:abandoned") m, _ = press(t, m, "esc") - m.userManageTable.SetCursor(1) + if m.mode != modeChannelMenu { + t.Fatalf("escape should go back to the menu, got mode %v", m.mode) + } - m, _ = press(t, m, "t") - if got := m.ntfyTopicInput.Value(); got != "" { - t.Errorf("expected an empty field for a user with no topic, got %q", got) + m, _ = press(t, m, "a") + if got := m.channelAddInput.Value(); got != "" { + t.Errorf("expected an empty field, got %q", got) } } -func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) { +func TestChannels_AddRejectsASpecWithoutTypeAndAddress(t *testing.T) { + for _, spec := range []string{"", "ntfy", "ntfy:", ":topic"} { + m, _ := press(t, onUsers(userFixtures()), "t") + m, _ = press(t, m, "a") + m.channelAddInput.SetValue(spec) + + m, cmd := press(t, m, "enter") + if m.mode != modeChannelAdd { + t.Errorf("%q: expected to stay on the form, got mode %v", spec, m.mode) + } + if m.statusMsg == "" { + t.Errorf("%q: expected an explanation", spec) + } + _ = cmd // only the status-clearing tick + } +} + +func TestChannels_AddSubmitsAndReturnsToTheDashboard(t *testing.T) { + m, _ := press(t, onUsers(userFixtures()), "t") + m, _ = press(t, m, "a") + m.channelAddInput.SetValue("ntfy:my-topic") + + m, cmd := press(t, m, "enter") + if cmd == nil { + t.Fatal("expected adding a channel to call the server") + } + if m.mode != modeDashboard { + t.Errorf("expected a return to the dashboard, got mode %v", m.mode) + } +} + +func TestChannels_RemoveNeedsAPositiveID(t *testing.T) { + m, _ := press(t, onUsers(userFixtures()), "t") + m, _ = press(t, m, "r") + if m.mode != modeChannelRemoveByID { + t.Fatalf("expected the remove form, got mode %v", m.mode) + } + + for _, bad := range []string{"", "abc", "0", "-3"} { + m.channelRemoveIn.SetValue(bad) + next, _ := press(t, m, "enter") + if next.mode != modeChannelRemoveByID { + t.Errorf("%q: expected to stay on the form, got mode %v", bad, next.mode) + } + } + + m.channelRemoveIn.SetValue("3") + m, cmd := press(t, m, "enter") + if cmd == nil || m.mode != modeDashboard { + t.Errorf("expected a valid ID to call the server and return, mode %v", m.mode) + } +} + +func TestChannels_EscapeAbandonsWithoutSaving(t *testing.T) { m, _ := press(t, onUsers(userFixtures()), "t") m, cmd := press(t, m, "esc") @@ -475,23 +529,7 @@ func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) { } } -// Clearing a topic is a real action, not a no-op: it is how a user is taken off -// their own topic and back onto the shared fallback. Contrast the snooze prompt, -// where an empty value means "I changed my mind". -func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) { - m, _ := press(t, onUsers(userFixtures()), "t") - m.ntfyTopicInput.SetValue("") - - m, cmd := press(t, m, "enter") - if cmd == nil { - t.Fatal("expected clearing the topic to call the server") - } - if m.mode != modeDashboard { - t.Errorf("expected a return to the dashboard, got mode %v", m.mode) - } -} - -func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) { +func TestChannels_IsUsersSectionOnly(t *testing.T) { m := sized() m.activeSection = sectionIncidents if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard { diff --git a/internal/tui/view.go b/internal/tui/view.go index 2b9cacf..5f5e2ad 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -161,8 +161,12 @@ func (m Model) renderBody() string { return m.renderTeamPicker() case modeUserCreate: return m.renderUserCreate() - case modeUserNotifyEdit: - return m.renderUserNotifyEdit() + case modeChannelMenu: + return m.renderChannelMenu() + case modeChannelAdd: + return m.renderChannelAdd() + case modeChannelRemoveByID: + return m.renderChannelRemoveByID() case modeAPIKeyMenu: return m.renderAPIKeyMenu() case modeAPIKeyCreate: @@ -222,8 +226,14 @@ func (m Model) renderFooter() string { case modeUserCreate: return withStatus(" tab·next field enter·create esc·cancel") - case modeUserNotifyEdit: - return withStatus(" enter·save esc·cancel (empty clears the topic)") + case modeChannelMenu: + return withStatus(" a·add a channel r·remove by ID esc·back") + + case modeChannelAdd: + return withStatus(" enter·add esc·back (type:address)") + + case modeChannelRemoveByID: + return withStatus(" enter·remove esc·back") case modeAPIKeyMenu: return withStatus(" n·new key r·revoke by ID esc·back") @@ -256,7 +266,7 @@ func (m Model) renderFooter() string { case sectionSchedule: return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit") case sectionUsers: - return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh L·sign out q·quit") + return withStatus(" n·new user t·channels d·delete k·API keys p·password r·refresh L·sign out q·quit") } return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp())) } @@ -748,10 +758,10 @@ func eventLabel(e api.IncidentEvent) string { return " Resolved (all alerts stopped firing)" case api.EventNotified: // An empty username here is not "the server acted": it means the page - // went to the shared fallback topic, so it belongs to nobody. + // went to the shared fallback, so it belongs to nobody. return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail)) case api.EventNotifyFailed: - // The detail is ": ", and the reason is the point — it is + // The detail is " via : ", and the reason is the point — it is // the only thing that says why nobody's phone rang. return truncate(fmt.Sprintf(" Notification to %s failed · %s", notifiedTarget(who), e.Detail), 52) @@ -767,11 +777,11 @@ func eventLabel(e api.IncidentEvent) string { } // notifiedTarget names who a page reached. The server attaches no user when it -// published to the shared fallback topic, and saying so is the difference +// published to the shared fallback, and saying so is the difference // between "somebody was paged" and "the on-call rota was empty". func notifiedTarget(username string) string { if username == "" { - return "the fallback topic" + return "the fallback" } return username } @@ -959,14 +969,44 @@ func (m Model) renderUserCreate() string { emailLabel + m.userFormInputs[1].View() + "\n" } -func (m Model) renderUserNotifyEdit() string { - header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username)) +func (m Model) renderChannelMenu() string { + header := fmt.Sprintf("\n Notification channels for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username)) + var list string + if len(m.selectedUser.Channels) == 0 { + list = line(m.styles.Muted, " None — their incidents page the server's shared fallback,\n"+ + " which carries no Acknowledge button.") + } + for _, c := range m.selectedUser.Channels { + state := "" + if !c.Enabled { + state = " (off)" + } + // The address is only sent to its owner; an administrator sees the kind. + address := c.Address + if address == "" { + address = "(address hidden)" + } + list += fmt.Sprintf(" %s %s %s%s\n", m.styles.Accent.Render(fmt.Sprintf("#%d", c.ID)), c.Type, address, state) + } + options := "\n" + + m.styles.Accent.Render(" a") + " · add a channel (type:address, e.g. ntfy:my-topic)\n" + + m.styles.Accent.Render(" r") + " · remove a channel by ID\n" + return header + list + options +} + +func (m Model) renderChannelAdd() string { + header := fmt.Sprintf("\n New channel for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username)) hint := line(m.styles.Muted, - " The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+ - " their incidents then page the server's shared fallback topic, which\n"+ - " carries no Acknowledge button.") - label := m.styles.Selected.Render(" Topic: ") - return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" + " The kind of channel, a colon, then where it goes — for ntfy, the topic.\n"+ + " Only kinds the server has configured are accepted.") + label := m.styles.Selected.Render(" Channel: ") + return header + hint + "\n" + label + m.channelAddInput.View() + "\n" +} + +func (m Model) renderChannelRemoveByID() string { + header := fmt.Sprintf("\n Remove a channel from %s\n\n", m.styles.Bold.Render(m.selectedUser.Username)) + label := m.styles.Selected.Render(" Channel ID: ") + return header + label + m.channelRemoveIn.View() + "\n" } func (m Model) renderPasswordSet() string { diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index c33cc2c..401c3eb 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -184,14 +184,17 @@ func TestEventLabel_KnownTypes(t *testing.T) { "Notified bo (triggered)"}, {api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"}, "Notified bo (reminder)"}, - // On a notification, no user means the shared fallback topic — not that + // The server names the channel type after the kind. + {api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered via ntfy"}, + "Notified bo (triggered via ntfy)"}, + // On a notification, no user means the shared fallback — not that // the server acted on its own. {api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"}, - "Notified the fallback topic (triggered)"}, - {api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"}, + "Notified the fallback (triggered)"}, + {api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered via ntfy: ntfy returned 502"}, "Notification to bo failed"}, {api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"}, - "Notification to the fallback topic failed"}, + "Notification to the fallback failed"}, } for _, tt := range tests { t.Run(tt.event.Type, func(t *testing.T) { @@ -216,14 +219,45 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) { mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") } -func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) { +func TestChannelMenu_ListsKindsAndSaysWhenThereAreNone(t *testing.T) { m := sized() - m.mode = modeUserNotifyEdit + m.mode = modeChannelMenu m.selectedUser = api.User{ID: 1, Username: "niklas"} - mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic") - // The footer has to repeat it: that is where the reader looks for what a key does. - mustContain(t, m.renderFooter(), "empty clears the topic") + mustContain(t, m.View(), "niklas", "None", "fallback", "add a channel", "remove a channel") + mustContain(t, m.renderFooter(), "add a channel", "remove by ID") + + // An administrator sees the kind and ID of somebody else's channel, never where it goes. + m.selectedUser.Channels = []api.NotificationChannel{{ID: 7, Type: "ntfy", Enabled: false}} + mustContain(t, m.View(), "#7", "ntfy", "(address hidden)", "(off)") +} + +func TestChannelAdd_ExplainsTheSpec(t *testing.T) { + m := sized() + m.mode = modeChannelAdd + m.selectedUser = api.User{ID: 1, Username: "niklas"} + + mustContain(t, m.View(), "niklas", "type", "colon") + mustContain(t, m.renderFooter(), "type:address") +} + +func TestParseChannelSpec(t *testing.T) { + for spec, want := range map[string][2]string{ + "ntfy:my-topic": {"ntfy", "my-topic"}, + " ntfy : my-topic ": {"ntfy", "my-topic"}, + "mail:a@b.example": {"mail", "a@b.example"}, + "ntfy:a:b": {"ntfy", "a:b"}, + } { + typ, address, ok := parseChannelSpec(spec) + if !ok || typ != want[0] || address != want[1] { + t.Errorf("%q: got %q, %q, %v", spec, typ, address, ok) + } + } + for _, bad := range []string{"", "ntfy", "ntfy:", ":topic", " "} { + if _, _, ok := parseChannelSpec(bad); ok { + t.Errorf("%q should not parse", bad) + } + } } func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {