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:
@@ -2,12 +2,17 @@ module github.com/yeniklas/terdut-tui
|
|||||||
|
|
||||||
go 1.25.9
|
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 (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
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/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/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // 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
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/text v0.3.8 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
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/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 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
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/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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -255,6 +255,47 @@ func (c *Client) ListUsers() ([]User, error) {
|
|||||||
return users, c.do(req, &users)
|
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).
|
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway).
|
||||||
func (c *Client) HealthCheck() error {
|
func (c *Client) HealthCheck() error {
|
||||||
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
||||||
|
|||||||
@@ -63,3 +63,12 @@ type User struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ const (
|
|||||||
modeConfirmDelete
|
modeConfirmDelete
|
||||||
modeStats
|
modeStats
|
||||||
modeScheduleUserPicker
|
modeScheduleUserPicker
|
||||||
|
modeUserCreate
|
||||||
|
modeAPIKeyMenu
|
||||||
|
modeAPIKeyCreate
|
||||||
|
modeAPIKeyReveal
|
||||||
|
modeAPIKeyRevokeByID
|
||||||
)
|
)
|
||||||
|
|
||||||
type confirmTarget int
|
type confirmTarget int
|
||||||
@@ -39,6 +44,7 @@ type confirmTarget int
|
|||||||
const (
|
const (
|
||||||
confirmDeleteComment confirmTarget = iota
|
confirmDeleteComment confirmTarget = iota
|
||||||
confirmDeleteScheduleEntry
|
confirmDeleteScheduleEntry
|
||||||
|
confirmDeleteUser
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Messages ───────────────────────────────────────────────────────────────
|
// ── Messages ───────────────────────────────────────────────────────────────
|
||||||
@@ -75,6 +81,11 @@ type scheduleFetchErrMsg struct{ err error }
|
|||||||
type scheduleActionErrMsg struct{ err error }
|
type scheduleActionErrMsg struct{ err error }
|
||||||
type usersFetchedMsg struct{ users []api.User }
|
type usersFetchedMsg struct{ users []api.User }
|
||||||
|
|
||||||
|
// user management
|
||||||
|
type apiKeyCreatedMsg struct{ key api.APIKey }
|
||||||
|
type apiKeyRevokedMsg struct{}
|
||||||
|
type userActionErrMsg struct{ err error }
|
||||||
|
|
||||||
// ── Model ──────────────────────────────────────────────────────────────────
|
// ── Model ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type scheduleDay struct {
|
type scheduleDay struct {
|
||||||
@@ -137,6 +148,15 @@ type Model struct {
|
|||||||
usersLoading bool
|
usersLoading bool
|
||||||
userPickerTable table.Model
|
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
|
help help.Model
|
||||||
keys keyMap
|
keys keyMap
|
||||||
}
|
}
|
||||||
@@ -153,10 +173,29 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
|||||||
pickerT := table.New(table.WithFocused(true))
|
pickerT := table.New(table.WithFocused(true))
|
||||||
pickerT.SetStyles(ts)
|
pickerT.SetStyles(ts)
|
||||||
|
|
||||||
|
manageT := table.New(table.WithFocused(true))
|
||||||
|
manageT.SetStyles(ts)
|
||||||
|
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "type your comment…"
|
ti.Placeholder = "type your comment…"
|
||||||
ti.CharLimit = 1000
|
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()
|
now := time.Now().UTC()
|
||||||
window := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
window := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
@@ -174,6 +213,10 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
|||||||
scheduleWindow: window,
|
scheduleWindow: window,
|
||||||
scheduleTable: schedT,
|
scheduleTable: schedT,
|
||||||
userPickerTable: pickerT,
|
userPickerTable: pickerT,
|
||||||
|
userManageTable: manageT,
|
||||||
|
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
|
||||||
|
apiKeyNameInput: keyNameIn,
|
||||||
|
apiKeyRevokeInput: revokeIn,
|
||||||
help: help.New(),
|
help: help.New(),
|
||||||
keys: keys,
|
keys: keys,
|
||||||
}
|
}
|
||||||
@@ -229,6 +272,20 @@ func (m *Model) rebuildUserPickerTable() {
|
|||||||
m.userPickerTable.SetHeight(h)
|
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() {
|
func (m *Model) refreshDetailContent() {
|
||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
return
|
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 ───────────────────────────────────────────────────────────
|
// ── Row builders ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func alertRows(alerts []api.Alert) []table.Row {
|
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 {
|
func tickCmd(interval time.Duration) tea.Cmd {
|
||||||
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
||||||
return tickMsg(t)
|
return tickMsg(t)
|
||||||
|
|||||||
+228
-1
@@ -1,10 +1,13 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/yeniklas/terdut-tui/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
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.rebuildTable()
|
||||||
m.rebuildScheduleTable()
|
m.rebuildScheduleTable()
|
||||||
m.rebuildUserPickerTable()
|
m.rebuildUserPickerTable()
|
||||||
|
m.rebuildUserManageTable()
|
||||||
m.detailViewport.Width = m.width
|
m.detailViewport.Width = m.width
|
||||||
m.detailViewport.Height = m.detailViewportHeight()
|
m.detailViewport.Height = m.detailViewportHeight()
|
||||||
m.statsViewport.Width = m.width
|
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.users = msg.users
|
||||||
m.usersLoading = false
|
m.usersLoading = false
|
||||||
m.rebuildUserPickerTable()
|
m.rebuildUserPickerTable()
|
||||||
|
m.rebuildUserManageTable()
|
||||||
return m, nil
|
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 ────────────────────────────────────────────────────────────
|
// ── Common ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
case clearStatusMsg:
|
case clearStatusMsg:
|
||||||
@@ -165,6 +186,27 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
m2, ourCmd := m.handleKey(msg)
|
m2, ourCmd := m.handleKey(msg)
|
||||||
return m2, tea.Batch(tableCmd, ourCmd)
|
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
|
default: // modeDashboard
|
||||||
if m.connected {
|
if m.connected {
|
||||||
switch m.activeSection {
|
switch m.activeSection {
|
||||||
@@ -178,6 +220,11 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
m.scheduleTable, tableCmd = m.scheduleTable.Update(msg)
|
m.scheduleTable, tableCmd = m.scheduleTable.Update(msg)
|
||||||
m2, ourCmd := m.handleKey(msg)
|
m2, ourCmd := m.handleKey(msg)
|
||||||
return m2, tea.Batch(tableCmd, ourCmd)
|
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)
|
return m.handleKey(msg)
|
||||||
@@ -196,6 +243,16 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m.handleStatsKey(msg)
|
return m.handleStatsKey(msg)
|
||||||
case modeScheduleUserPicker:
|
case modeScheduleUserPicker:
|
||||||
return m.handleUserPickerKey(msg)
|
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:
|
default:
|
||||||
return m.handleDashboardKey(msg)
|
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)
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
||||||
return m, fetchScheduleCmd(m.client, from, to)
|
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
|
return m, nil
|
||||||
|
|
||||||
case "r":
|
case "r":
|
||||||
@@ -229,6 +290,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
to := m.scheduleWindow.AddDate(0, 0, 13)
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
||||||
return m, fetchScheduleCmd(m.client, from, to)
|
return m, fetchScheduleCmd(m.client, from, to)
|
||||||
}
|
}
|
||||||
|
if m.activeSection == sectionUsers {
|
||||||
|
m.usersLoading = true
|
||||||
|
return m, fetchUsersCmd(m.client)
|
||||||
|
}
|
||||||
m.statusMsg = "Refreshing…"
|
m.statusMsg = "Refreshing…"
|
||||||
return m, tea.Batch(
|
return m, tea.Batch(
|
||||||
fetchAlertsCmd(m.client, m.filterStatus),
|
fetchAlertsCmd(m.client, m.filterStatus),
|
||||||
@@ -299,7 +364,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "d":
|
case "d":
|
||||||
if m.activeSection != sectionSchedule || !m.connected {
|
switch m.activeSection {
|
||||||
|
case sectionSchedule:
|
||||||
|
if !m.connected {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
cursor := m.scheduleTable.Cursor()
|
cursor := m.scheduleTable.Cursor()
|
||||||
@@ -314,6 +381,42 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
m.pendingDeleteEntry = day.entry
|
m.pendingDeleteEntry = day.entry
|
||||||
m.confirmTarget = confirmDeleteScheduleEntry
|
m.confirmTarget = confirmDeleteScheduleEntry
|
||||||
m.mode = modeConfirmDelete
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
cursor := m.userManageTable.Cursor()
|
||||||
|
if cursor >= len(m.users) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.selectedUser = m.users[cursor]
|
||||||
|
m.mode = modeAPIKeyMenu
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +546,11 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
from := m.scheduleWindow
|
from := m.scheduleWindow
|
||||||
to := m.scheduleWindow.AddDate(0, 0, 13)
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
||||||
return m, deleteScheduleEntryCmd(m.client, entry.ID, from, to)
|
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:
|
default:
|
||||||
switch m.confirmTarget {
|
switch m.confirmTarget {
|
||||||
@@ -450,6 +558,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
m.mode = modeDetail
|
m.mode = modeDetail
|
||||||
case confirmDeleteScheduleEntry:
|
case confirmDeleteScheduleEntry:
|
||||||
m.mode = modeDashboard
|
m.mode = modeDashboard
|
||||||
|
case confirmDeleteUser:
|
||||||
|
m.mode = modeDashboard
|
||||||
}
|
}
|
||||||
m.pendingDeleteID = 0
|
m.pendingDeleteID = 0
|
||||||
m.pendingDeleteEntry = nil
|
m.pendingDeleteEntry = nil
|
||||||
@@ -496,3 +606,120 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
|
|
||||||
return m, nil
|
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
@@ -62,14 +62,28 @@ func (m Model) renderBody() string {
|
|||||||
case modeComment:
|
case modeComment:
|
||||||
return m.renderCommentCompose()
|
return m.renderCommentCompose()
|
||||||
case modeConfirmDelete:
|
case modeConfirmDelete:
|
||||||
if m.confirmTarget == confirmDeleteComment {
|
switch m.confirmTarget {
|
||||||
|
case confirmDeleteComment:
|
||||||
return m.renderDetail()
|
return m.renderDetail()
|
||||||
}
|
case confirmDeleteUser:
|
||||||
|
return m.renderUsers()
|
||||||
|
default:
|
||||||
return m.renderSchedule()
|
return m.renderSchedule()
|
||||||
|
}
|
||||||
case modeStats:
|
case modeStats:
|
||||||
return m.renderStats()
|
return m.renderStats()
|
||||||
case modeScheduleUserPicker:
|
case modeScheduleUserPicker:
|
||||||
return m.renderUserPicker()
|
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:
|
default:
|
||||||
return m.renderDashboard()
|
return m.renderDashboard()
|
||||||
}
|
}
|
||||||
@@ -102,6 +116,8 @@ func (m Model) renderFooter() string {
|
|||||||
} else {
|
} else {
|
||||||
deleteDesc = "schedule entry"
|
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))
|
return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc))
|
||||||
|
|
||||||
@@ -114,6 +130,34 @@ func (m Model) renderFooter() string {
|
|||||||
case modeScheduleUserPicker:
|
case modeScheduleUserPicker:
|
||||||
return "\n" + styleFooter.Render(" j/k·navigate enter·select esc·cancel")
|
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:
|
default:
|
||||||
if m.activeSection == sectionSchedule {
|
if m.activeSection == sectionSchedule {
|
||||||
actions := styleFooter.Render(" +·assign d·del ←/→·shift week tab·section r·refresh q·quit")
|
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
|
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()))
|
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||||||
if m.statusMsg != "" {
|
if m.statusMsg != "" {
|
||||||
status := styleStatus.Render(m.statusMsg)
|
status := styleStatus.Render(m.statusMsg)
|
||||||
@@ -144,7 +195,7 @@ func (m Model) renderDashboard() string {
|
|||||||
case sectionSchedule:
|
case sectionSchedule:
|
||||||
return m.renderSchedule()
|
return m.renderSchedule()
|
||||||
case sectionUsers:
|
case sectionUsers:
|
||||||
return "\n" + styleMuted.Render(" User management — coming in Stage 5")
|
return m.renderUsers()
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -424,6 +475,73 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
|
|||||||
return b.String()
|
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 ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func divider(title string, width int) string {
|
func divider(title string, width int) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user