diff --git a/go.mod b/go.mod index e5fde00..1b51992 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,17 @@ module github.com/yeniklas/terdut-tui go 1.25.9 +require ( + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + gopkg.in/yaml.v3 v3.0.1 +) + require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -26,5 +31,4 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 573ad57..8f37950 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -14,6 +16,8 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -42,12 +46,15 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go index 804e3c6..7f96a73 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -255,6 +255,47 @@ func (c *Client) ListUsers() ([]User, error) { return users, c.do(req, &users) } +func (c *Client) CreateUser(username, email string) (*User, error) { + body := struct { + Username string `json:"username"` + Email string `json:"email"` + }{Username: username, Email: email} + req, err := c.newRequestWithBody(http.MethodPost, "/api/users", body) + if err != nil { + return nil, err + } + var user User + return &user, c.do(req, &user) +} + +func (c *Client) DeleteUser(id int64) error { + req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id)) + if err != nil { + return err + } + return c.do(req, nil) +} + +func (c *Client) CreateAPIKey(userID int64, name string) (*APIKey, error) { + body := struct { + Name string `json:"name"` + }{Name: name} + req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/api-keys", userID), body) + if err != nil { + return nil, err + } + var key APIKey + return &key, c.do(req, &key) +} + +func (c *Client) DeleteAPIKey(userID, keyID int64) error { + req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/api-keys/%d", userID, keyID)) + 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) diff --git a/internal/api/types.go b/internal/api/types.go index 7e72eb8..5e6c8ee 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -63,3 +63,12 @@ type User struct { Email string `json:"email"` CreatedAt time.Time `json:"created_at"` } + +type APIKey struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Name string `json:"name"` + Key string `json:"key,omitempty"` // only present in create response + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at"` +} diff --git a/internal/tui/model.go b/internal/tui/model.go index c5ba853..a303b33 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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) diff --git a/internal/tui/update.go b/internal/tui/update.go index 4b1dd6f..337105e 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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 +} diff --git a/internal/tui/view.go b/internal/tui/view.go index d2b0ee9..eb8f180 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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 {