feat: Stage 5 — user management and API key lifecycle

Add full user management section (tab to Users):
- User list table with username, email, created date
- n: create user (username + email form)
- d: delete user with confirmation (cascades API keys)
- k: API key management for selected user
  - n: create new key with name input; one-time reveal
    showing key value and integer key ID prominently
  - r: revoke by integer ID (no list endpoint on server)
- c: copy revealed key to clipboard (atotto/clipboard)

API key listing is unavailable server-side, so the reveal
screen displays the key ID prominently for future revocation.
This commit is contained in:
Niklas Ye
2026-05-21 14:14:07 +02:00
parent ba7a862789
commit 9a50770538
7 changed files with 556 additions and 34 deletions
+133 -17
View File
@@ -32,6 +32,11 @@ const (
modeConfirmDelete
modeStats
modeScheduleUserPicker
modeUserCreate
modeAPIKeyMenu
modeAPIKeyCreate
modeAPIKeyReveal
modeAPIKeyRevokeByID
)
type confirmTarget int
@@ -39,6 +44,7 @@ type confirmTarget int
const (
confirmDeleteComment confirmTarget = iota
confirmDeleteScheduleEntry
confirmDeleteUser
)
// ── Messages ───────────────────────────────────────────────────────────────
@@ -75,6 +81,11 @@ type scheduleFetchErrMsg struct{ err error }
type scheduleActionErrMsg struct{ err error }
type usersFetchedMsg struct{ users []api.User }
// user management
type apiKeyCreatedMsg struct{ key api.APIKey }
type apiKeyRevokedMsg struct{}
type userActionErrMsg struct{ err error }
// ── Model ──────────────────────────────────────────────────────────────────
type scheduleDay struct {
@@ -133,10 +144,19 @@ type Model struct {
scheduleTable table.Model
// User picker (schedule assignment)
users []api.User
usersLoading bool
users []api.User
usersLoading bool
userPickerTable table.Model
// User management section
userManageTable table.Model
selectedUser api.User
userFormInputs [2]textinput.Model
userFormFocus int
apiKeyNameInput textinput.Model
apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey
help help.Model
keys keyMap
}
@@ -153,29 +173,52 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
pickerT := table.New(table.WithFocused(true))
pickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true))
manageT.SetStyles(ts)
ti := textinput.New()
ti.Placeholder = "type your comment…"
ti.CharLimit = 1000
usernameIn := textinput.New()
usernameIn.Placeholder = "username"
usernameIn.CharLimit = 64
emailIn := textinput.New()
emailIn.Placeholder = "email"
emailIn.CharLimit = 128
keyNameIn := textinput.New()
keyNameIn.Placeholder = "key name (e.g. laptop)"
keyNameIn.CharLimit = 64
revokeIn := textinput.New()
revokeIn.Placeholder = "integer key ID"
revokeIn.CharLimit = 20
now := time.Now().UTC()
window := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
return Model{
client: client,
serverURL: serverURL,
refreshInterval: refreshInterval,
activeSection: sectionAlerts,
mode: modeDashboard,
loading: true,
filterStatus: "firing",
commentCursor: -1,
alertTable: alertT,
commentInput: ti,
scheduleWindow: window,
scheduleTable: schedT,
userPickerTable: pickerT,
help: help.New(),
keys: keys,
client: client,
serverURL: serverURL,
refreshInterval: refreshInterval,
activeSection: sectionAlerts,
mode: modeDashboard,
loading: true,
filterStatus: "firing",
commentCursor: -1,
alertTable: alertT,
commentInput: ti,
scheduleWindow: window,
scheduleTable: schedT,
userPickerTable: pickerT,
userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn,
help: help.New(),
keys: keys,
}
}
@@ -229,6 +272,20 @@ func (m *Model) rebuildUserPickerTable() {
m.userPickerTable.SetHeight(h)
}
func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users))
for i, u := range m.users {
rows[i] = table.Row{u.Username, u.Email, u.CreatedAt.UTC().Format("2006-01-02")}
}
m.userManageTable.SetRows(rows)
h := m.height - 10
if h < 1 {
h = 1
}
m.userManageTable.SetHeight(h)
}
func (m *Model) refreshDetailContent() {
if m.width == 0 {
return
@@ -294,6 +351,20 @@ func userPickerColumns(width int) []table.Column {
}
}
func userManageColumns(width int) []table.Column {
createdW := 12
usernameW := 25
emailW := width - usernameW - createdW - 8
if emailW < 15 {
emailW = 15
}
return []table.Column{
{Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW},
{Title: "Created", Width: createdW},
}
}
// ── Row builders ───────────────────────────────────────────────────────────
func alertRows(alerts []api.Alert) []table.Row {
@@ -556,6 +627,51 @@ func fetchUsersCmd(client *api.Client) tea.Cmd {
}
}
func createUserCmd(client *api.Client, username, email string) tea.Cmd {
return func() tea.Msg {
if _, err := client.CreateUser(username, email); err != nil {
return userActionErrMsg{err}
}
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
return usersFetchedMsg{users: users}
}
}
func deleteUserCmd(client *api.Client, userID int64) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteUser(userID); err != nil {
return userActionErrMsg{err}
}
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
return usersFetchedMsg{users: users}
}
}
func createAPIKeyCmd(client *api.Client, userID int64, name string) tea.Cmd {
return func() tea.Msg {
key, err := client.CreateAPIKey(userID, name)
if err != nil {
return userActionErrMsg{err}
}
return apiKeyCreatedMsg{key: *key}
}
}
func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteAPIKey(userID, keyID); err != nil {
return userActionErrMsg{err}
}
return apiKeyRevokedMsg{}
}
}
func tickCmd(interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(t time.Time) tea.Msg {
return tickMsg(t)
+237 -10
View File
@@ -1,10 +1,13 @@
package tui
import (
"strconv"
"strings"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -15,6 +18,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildTable()
m.rebuildScheduleTable()
m.rebuildUserPickerTable()
m.rebuildUserManageTable()
m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight()
m.statsViewport.Width = m.width
@@ -119,8 +123,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.users = msg.users
m.usersLoading = false
m.rebuildUserPickerTable()
m.rebuildUserManageTable()
return m, nil
case apiKeyCreatedMsg:
m.revealedAPIKey = msg.key
m.mode = modeAPIKeyReveal
return m, nil
case apiKeyRevokedMsg:
m.statusMsg = "API key revoked"
m.mode = modeDashboard
return m, clearStatusCmd()
case userActionErrMsg:
m.usersLoading = false
m.statusMsg = "error: " + msg.err.Error()
m.mode = modeDashboard
return m, clearStatusCmd()
// ── Common ────────────────────────────────────────────────────────────
case clearStatusMsg:
@@ -165,6 +186,27 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case modeUserCreate:
var inputCmd tea.Cmd
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeAPIKeyCreate:
var inputCmd tea.Cmd
m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeAPIKeyRevokeByID:
var inputCmd tea.Cmd
m.apiKeyRevokeInput, inputCmd = m.apiKeyRevokeInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeAPIKeyMenu, modeAPIKeyReveal:
return m.handleKey(msg)
default: // modeDashboard
if m.connected {
switch m.activeSection {
@@ -178,6 +220,11 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.scheduleTable, tableCmd = m.scheduleTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case sectionUsers:
var tableCmd tea.Cmd
m.userManageTable, tableCmd = m.userManageTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
}
}
return m.handleKey(msg)
@@ -196,6 +243,16 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleStatsKey(msg)
case modeScheduleUserPicker:
return m.handleUserPickerKey(msg)
case modeUserCreate:
return m.handleUserCreateKey(msg)
case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg)
case modeAPIKeyCreate:
return m.handleAPIKeyCreateKey(msg)
case modeAPIKeyReveal:
return m.handleAPIKeyRevealKey(msg)
case modeAPIKeyRevokeByID:
return m.handleAPIKeyRevokeKey(msg)
default:
return m.handleDashboardKey(msg)
}
@@ -217,6 +274,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
to := m.scheduleWindow.AddDate(0, 0, 13)
return m, fetchScheduleCmd(m.client, from, to)
}
if next == sectionUsers && len(m.users) == 0 {
m.usersLoading = true
return m, fetchUsersCmd(m.client)
}
return m, nil
case "r":
@@ -229,6 +290,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
to := m.scheduleWindow.AddDate(0, 0, 13)
return m, fetchScheduleCmd(m.client, from, to)
}
if m.activeSection == sectionUsers {
m.usersLoading = true
return m, fetchUsersCmd(m.client)
}
m.statusMsg = "Refreshing…"
return m, tea.Batch(
fetchAlertsCmd(m.client, m.filterStatus),
@@ -299,21 +364,59 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
case "d":
if m.activeSection != sectionSchedule || !m.connected {
switch m.activeSection {
case sectionSchedule:
if !m.connected {
return m, nil
}
cursor := m.scheduleTable.Cursor()
if cursor >= len(m.scheduleDays) {
return m, nil
}
day := m.scheduleDays[cursor]
if day.entry == nil {
m.statusMsg = "no assignment to delete on this date"
return m, clearStatusCmd()
}
m.pendingDeleteEntry = day.entry
m.confirmTarget = confirmDeleteScheduleEntry
m.mode = modeConfirmDelete
case sectionUsers:
if !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]
m.confirmTarget = confirmDeleteUser
m.mode = modeConfirmDelete
}
return m, nil
case "n":
if m.activeSection != sectionUsers || !m.connected {
return m, nil
}
cursor := m.scheduleTable.Cursor()
if cursor >= len(m.scheduleDays) {
m.userFormInputs[0].Reset()
m.userFormInputs[1].Reset()
m.userFormFocus = 0
m.userFormInputs[0].Focus()
m.userFormInputs[1].Blur()
m.mode = modeUserCreate
return m, nil
case "k":
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
return m, nil
}
day := m.scheduleDays[cursor]
if day.entry == nil {
m.statusMsg = "no assignment to delete on this date"
return m, clearStatusCmd()
cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) {
return m, nil
}
m.pendingDeleteEntry = day.entry
m.confirmTarget = confirmDeleteScheduleEntry
m.mode = modeConfirmDelete
m.selectedUser = m.users[cursor]
m.mode = modeAPIKeyMenu
return m, nil
}
@@ -443,6 +546,11 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
return m, deleteScheduleEntryCmd(m.client, entry.ID, from, to)
case confirmDeleteUser:
userID := m.selectedUser.ID
m.mode = modeDashboard
m.usersLoading = true
return m, deleteUserCmd(m.client, userID)
}
default:
switch m.confirmTarget {
@@ -450,6 +558,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDetail
case confirmDeleteScheduleEntry:
m.mode = modeDashboard
case confirmDeleteUser:
m.mode = modeDashboard
}
m.pendingDeleteID = 0
m.pendingDeleteEntry = nil
@@ -496,3 +606,120 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
// ── User management ───────────────────────────────────────────────────────────
func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.userFormInputs[0].Blur()
m.userFormInputs[1].Blur()
m.mode = modeDashboard
return m, nil
case "tab", "shift+tab":
m.userFormInputs[m.userFormFocus].Blur()
m.userFormFocus = (m.userFormFocus + 1) % 2
m.userFormInputs[m.userFormFocus].Focus()
return m, nil
case "enter":
username := strings.TrimSpace(m.userFormInputs[0].Value())
email := strings.TrimSpace(m.userFormInputs[1].Value())
if username == "" || email == "" {
m.statusMsg = "username and email are required"
return m, clearStatusCmd()
}
m.userFormInputs[0].Blur()
m.userFormInputs[1].Blur()
m.mode = modeDashboard
m.usersLoading = true
return m, createUserCmd(m.client, username, email)
}
return m, nil
}
func (m Model) handleAPIKeyMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeDashboard
return m, nil
case "n":
m.apiKeyNameInput.Reset()
m.apiKeyNameInput.Focus()
m.mode = modeAPIKeyCreate
return m, nil
case "r":
m.apiKeyRevokeInput.Reset()
m.apiKeyRevokeInput.Focus()
m.mode = modeAPIKeyRevokeByID
return m, nil
}
return m, nil
}
func (m Model) handleAPIKeyCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.apiKeyNameInput.Blur()
m.mode = modeAPIKeyMenu
return m, nil
case "enter":
name := strings.TrimSpace(m.apiKeyNameInput.Value())
if name == "" {
m.statusMsg = "key name is required"
return m, clearStatusCmd()
}
m.apiKeyNameInput.Blur()
m.mode = modeDashboard
return m, createAPIKeyCmd(m.client, m.selectedUser.ID, name)
}
return m, nil
}
func (m Model) handleAPIKeyRevealKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "c":
if err := clipboard.WriteAll(m.revealedAPIKey.Key); err != nil {
m.statusMsg = "clipboard error: " + err.Error()
} else {
m.statusMsg = "copied to clipboard"
}
return m, clearStatusCmd()
case "esc", "enter", "q":
m.revealedAPIKey = api.APIKey{}
m.mode = modeDashboard
return m, nil
}
return m, nil
}
func (m Model) handleAPIKeyRevokeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.apiKeyRevokeInput.Blur()
m.mode = modeAPIKeyMenu
return m, nil
case "enter":
raw := strings.TrimSpace(m.apiKeyRevokeInput.Value())
keyID, err := strconv.ParseInt(raw, 10, 64)
if err != nil || keyID <= 0 {
m.statusMsg = "invalid key ID — must be a positive integer"
return m, clearStatusCmd()
}
m.apiKeyRevokeInput.Blur()
m.mode = modeDashboard
return m, deleteAPIKeyCmd(m.client, m.selectedUser.ID, keyID)
}
return m, nil
}
+121 -3
View File
@@ -62,14 +62,28 @@ func (m Model) renderBody() string {
case modeComment:
return m.renderCommentCompose()
case modeConfirmDelete:
if m.confirmTarget == confirmDeleteComment {
switch m.confirmTarget {
case confirmDeleteComment:
return m.renderDetail()
case confirmDeleteUser:
return m.renderUsers()
default:
return m.renderSchedule()
}
return m.renderSchedule()
case modeStats:
return m.renderStats()
case modeScheduleUserPicker:
return m.renderUserPicker()
case modeUserCreate:
return m.renderUserCreate()
case modeAPIKeyMenu:
return m.renderAPIKeyMenu()
case modeAPIKeyCreate:
return m.renderAPIKeyCreate()
case modeAPIKeyReveal:
return m.renderAPIKeyReveal()
case modeAPIKeyRevokeByID:
return m.renderAPIKeyRevokeByID()
default:
return m.renderDashboard()
}
@@ -102,6 +116,8 @@ func (m Model) renderFooter() string {
} else {
deleteDesc = "schedule entry"
}
case confirmDeleteUser:
deleteDesc = fmt.Sprintf("user %s (cascades all API keys)", m.selectedUser.Username)
}
return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc))
@@ -114,6 +130,34 @@ func (m Model) renderFooter() string {
case modeScheduleUserPicker:
return "\n" + styleFooter.Render(" j/k·navigate enter·select esc·cancel")
case modeUserCreate:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel")
}
return "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel")
case modeAPIKeyMenu:
return "\n" + styleFooter.Render(" n·new key r·revoke by ID esc·back")
case modeAPIKeyCreate:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·create esc·back")
}
return "\n" + styleFooter.Render(" enter·create esc·back")
case modeAPIKeyReveal:
footer := " c·copy to clipboard esc·done"
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(footer)
}
return "\n" + styleFooter.Render(footer)
case modeAPIKeyRevokeByID:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·revoke esc·back")
}
return "\n" + styleFooter.Render(" enter·revoke esc·back")
default:
if m.activeSection == sectionSchedule {
actions := styleFooter.Render(" +·assign d·del ←/→·shift week tab·section r·refresh q·quit")
@@ -122,6 +166,13 @@ func (m Model) renderFooter() string {
}
return "\n" + actions
}
if m.activeSection == sectionUsers {
actions := styleFooter.Render(" n·new user d·delete k·API keys r·refresh tab·section q·quit")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
}
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
if m.statusMsg != "" {
status := styleStatus.Render(m.statusMsg)
@@ -144,7 +195,7 @@ func (m Model) renderDashboard() string {
case sectionSchedule:
return m.renderSchedule()
case sectionUsers:
return "\n" + styleMuted.Render(" User management — coming in Stage 5")
return m.renderUsers()
}
return ""
}
@@ -424,6 +475,73 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
return b.String()
}
// ── Users ──────────────────────────────────────────────────────────────────
func (m Model) renderUsers() string {
if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…")
}
if len(m.users) == 0 {
return "\n" + styleMuted.Render(" No users found. Press n to create one.")
}
return "\n" + m.userManageTable.View()
}
func (m Model) renderUserCreate() string {
header := "\n " + styleBold.Render("Create new user") + "\n\n"
usernameLabel := " Username: "
emailLabel := " Email: "
if m.userFormFocus == 0 {
usernameLabel = styleSelected.Render(" Username: ")
} else {
emailLabel = styleSelected.Render(" Email: ")
}
return header +
usernameLabel + m.userFormInputs[0].View() + "\n" +
emailLabel + m.userFormInputs[1].View() + "\n"
}
func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username))
warning := styleMuted.Render(" Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.\n")
options := "\n" +
styleAccent.Render(" n") + " · create a new API key\n" +
styleAccent.Render(" r") + " · revoke a key by ID\n"
return header + "\n" + warning + options
}
func (m Model) renderAPIKeyCreate() string {
header := fmt.Sprintf("\n New API key for %s\n\n", styleBold.Render(m.selectedUser.Username))
label := styleSelected.Render(" Key name: ")
return header + label + m.apiKeyNameInput.View() + "\n"
}
func (m Model) renderAPIKeyReveal() string {
sep := styleMuted.Render(strings.Repeat("─", m.width))
warn := styleError.Render(" !! COPY NOW — this key will NEVER be shown again !!")
nameLine := fmt.Sprintf(" Key name: %s", styleBold.Render(m.revealedAPIKey.Name))
idLine := fmt.Sprintf(" Key ID: %s %s",
styleBold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
styleMuted.Render("(save this — needed for future revocation)"))
keyLine := styleResolved.Render(" " + m.revealedAPIKey.Key)
return "\n" + sep + "\n\n" +
warn + "\n\n" +
nameLine + "\n" +
idLine + "\n\n" +
styleMuted.Render(" Key value:") + "\n" +
keyLine + "\n\n" +
sep + "\n"
}
func (m Model) renderAPIKeyRevokeByID() string {
header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username))
hint := styleMuted.Render(" Enter the integer key ID (shown when the key was created).\n")
label := styleSelected.Render(" Key ID: ")
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
}
// ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string {