repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / tui / pubkeys
Antonio Mika · 31 May 24

keys.go

  1package pubkeys
  2
  3import (
  4	pager "github.com/charmbracelet/bubbles/paginator"
  5	tea "github.com/charmbracelet/bubbletea"
  6	"github.com/picosh/pico/db"
  7	"github.com/picosh/pico/tui/common"
  8	"github.com/picosh/pico/tui/pages"
  9)
 10
 11const keysPerPage = 4
 12
 13type state int
 14
 15const (
 16	stateLoading state = iota
 17	stateNormal
 18	stateDeletingKey
 19	stateDeletingActiveKey
 20	stateDeletingAccount
 21	stateQuitting
 22)
 23
 24type keyState int
 25
 26const (
 27	keyNormal keyState = iota
 28	keySelected
 29	keyDeleting
 30)
 31
 32type (
 33	keysLoadedMsg  []*db.PublicKey
 34	unlinkedKeyMsg int
 35)
 36
 37// Model is the Tea state model for this user interface.
 38type Model struct {
 39	shared common.SharedModel
 40
 41	state          state
 42	err            error
 43	activeKeyIndex int             // index of the key in the below slice which is currently in use
 44	keys           []*db.PublicKey // keys linked to user's account
 45	index          int             // index of selected key in relation to the current page
 46
 47	pager pager.Model
 48}
 49
 50// NewModel creates a new model with defaults.
 51func NewModel(shared common.SharedModel) Model {
 52	p := pager.New()
 53	p.PerPage = keysPerPage
 54	p.Type = pager.Dots
 55	p.InactiveDot = shared.Styles.InactivePagination.Render("•")
 56
 57	return Model{
 58		shared: shared,
 59
 60		pager:          p,
 61		state:          stateLoading,
 62		err:            nil,
 63		activeKeyIndex: -1,
 64		keys:           []*db.PublicKey{},
 65		index:          0,
 66	}
 67}
 68
 69// getSelectedIndex returns the index of the cursor in relation to the total
 70// number of items.
 71func (m *Model) getSelectedIndex() int {
 72	return m.index + m.pager.Page*m.pager.PerPage
 73}
 74
 75// UpdatePaging runs an update against the underlying pagination model as well
 76// as performing some related tasks on this model.
 77func (m *Model) UpdatePaging(msg tea.Msg) {
 78	// Handle paging
 79	m.pager.SetTotalPages(len(m.keys))
 80	m.pager, _ = m.pager.Update(msg)
 81
 82	// If selected item is out of bounds, put it in bounds
 83	numItems := m.pager.ItemsOnPage(len(m.keys))
 84	m.index = min(m.index, numItems-1)
 85}
 86
 87// Init is the Tea initialization function.
 88func (m Model) Init() tea.Cmd {
 89	return FetchKeys(m.shared)
 90}
 91
 92// Update is the tea update function which handles incoming messages.
 93func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 94	var (
 95		cmds []tea.Cmd
 96	)
 97
 98	switch msg := msg.(type) {
 99	case tea.KeyMsg:
100		switch msg.String() {
101		case "q", "esc":
102			return m, pages.Navigate(pages.MenuPage)
103		case "up", "k":
104			m.index -= 1
105			if m.index < 0 && m.pager.Page > 0 {
106				m.index = m.pager.PerPage - 1
107				m.pager.PrevPage()
108			}
109			m.index = max(0, m.index)
110		case "down", "j":
111			itemsOnPage := m.pager.ItemsOnPage(len(m.keys))
112			m.index += 1
113			if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
114				m.index = 0
115				m.pager.NextPage()
116			}
117			m.index = min(itemsOnPage-1, m.index)
118
119		case "n":
120			return m, pages.Navigate(pages.CreatePubkeyPage)
121
122		// Delete
123		case "x":
124			m.state = stateDeletingKey
125			m.UpdatePaging(msg)
126			return m, nil
127
128		// Confirm Delete
129		case "y":
130			switch m.state {
131			case stateDeletingKey:
132				if len(m.keys) == 1 {
133					// The user is about to delete her account. Double confirm.
134					m.state = stateDeletingAccount
135					return m, nil
136				}
137				if m.getSelectedIndex() == m.activeKeyIndex {
138					// The user is going to delete her active key. Double confirm.
139					m.state = stateDeletingActiveKey
140					return m, nil
141				}
142				m.state = stateNormal
143				return m, m.unlinkKey()
144			case stateDeletingActiveKey:
145				m.state = stateQuitting
146				// Active key will be deleted. Remove the key and exit.
147				return m, m.unlinkKey()
148			case stateDeletingAccount:
149				// Account will be deleted. Remove the key and exit.
150				m.state = stateQuitting
151				return m, m.deleteAccount()
152			}
153		}
154
155	case common.ErrMsg:
156		m.err = msg.Err
157		return m, nil
158
159	case keysLoadedMsg:
160		m.state = stateNormal
161		m.index = 0
162		m.keys = msg
163		for i, key := range m.keys {
164			if key.Key == m.shared.User.PublicKey.Key {
165				m.activeKeyIndex = i
166			}
167		}
168
169	case unlinkedKeyMsg:
170		if m.state == stateQuitting {
171			return m, tea.Quit
172		}
173		i := m.getSelectedIndex()
174
175		// Remove key from array
176		m.keys = append(m.keys[:i], m.keys[i+1:]...)
177
178		// Update pagination
179		m.pager.SetTotalPages(len(m.keys))
180		m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
181
182		// Update cursor
183		m.index = min(m.index, m.pager.ItemsOnPage(len(m.keys)-1))
184		for i, key := range m.keys {
185			if key.Key == m.shared.User.PublicKey.Key {
186				m.activeKeyIndex = i
187			}
188		}
189
190		return m, nil
191
192	// leaving page so reset model
193	case pages.NavigateMsg:
194		next := NewModel(m.shared)
195		return next, next.Init()
196	}
197
198	switch m.state {
199	case stateDeletingKey:
200		// If an item is being confirmed for delete, any key (other than the key
201		// used for confirmation above) cancels the deletion
202		k, ok := msg.(tea.KeyMsg)
203		if ok && k.String() != "y" {
204			m.state = stateNormal
205		}
206	}
207
208	m.UpdatePaging(msg)
209	return m, tea.Batch(cmds...)
210}
211
212// View renders the current UI into a string.
213func (m Model) View() string {
214	if m.err != nil {
215		return m.err.Error()
216	}
217
218	var s string
219
220	switch m.state {
221	case stateLoading:
222		s = "Loading...\n\n"
223	case stateQuitting:
224		s = "Thanks for using pico.sh!\n"
225	default:
226		s = "Here are the pubkeys linked to your account. Add more pubkeys to be able to login with multiple SSH keypairs.\n\n"
227
228		// Keys
229		s += m.keysView()
230		if m.pager.TotalPages > 1 {
231			s += m.pager.View()
232		}
233
234		// Footer
235		switch m.state {
236		case stateDeletingKey:
237			s += m.promptView("Delete this key?")
238		case stateDeletingActiveKey:
239			s += m.promptView("This is the key currently in use. Are you, like, for-sure-for-sure?")
240		case stateDeletingAccount:
241			s += m.promptView("Sure? This will delete your account. Are you absolutely positive?")
242		default:
243			s += "\n\n" + m.helpView()
244		}
245	}
246
247	return s
248}
249
250func (m *Model) keysView() string {
251	var (
252		s          string
253		state      keyState
254		start, end = m.pager.GetSliceBounds(len(m.keys))
255		slice      = m.keys[start:end]
256	)
257
258	destructiveState :=
259		(m.state == stateDeletingKey ||
260			m.state == stateDeletingActiveKey ||
261			m.state == stateDeletingAccount)
262
263	// Render key info
264	for i, key := range slice {
265		if destructiveState && m.index == i {
266			state = keyDeleting
267		} else if m.index == i {
268			state = keySelected
269		} else {
270			state = keyNormal
271		}
272		s += m.newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
273	}
274
275	// If there aren't enough keys to fill the view, fill the missing parts
276	// with whitespace
277	if len(slice) < m.pager.PerPage {
278		for i := len(slice); i < m.pager.PerPage; i++ {
279			s += "\n\n\n"
280		}
281	}
282
283	return s
284}
285
286func (m *Model) helpView() string {
287	var items []string
288	if len(m.keys) > 1 {
289		items = append(items, "j/k, ↑/↓: choose")
290	}
291	if m.pager.TotalPages > 1 {
292		items = append(items, "h/l, ←/→: page")
293	}
294	items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
295	return common.HelpView(m.shared.Styles, items...)
296}
297
298func (m *Model) promptView(prompt string) string {
299	st := m.shared.Styles.Delete.MarginTop(2).MarginRight(1)
300	return st.Render(prompt) +
301		m.shared.Styles.Delete.Render("(y/N)")
302}
303
304// FetchKeys loads the current set of keys via the charm client.
305func FetchKeys(shrd common.SharedModel) tea.Cmd {
306	return func() tea.Msg {
307		ak, err := shrd.Dbpool.FindKeysForUser(shrd.User)
308		if err != nil {
309			return common.ErrMsg{Err: err}
310		}
311		return keysLoadedMsg(ak)
312	}
313}
314
315// unlinkKey deletes the selected key.
316func (m *Model) unlinkKey() tea.Cmd {
317	return func() tea.Msg {
318		id := m.keys[m.getSelectedIndex()].ID
319		err := m.shared.Dbpool.RemoveKeys([]string{id})
320		if err != nil {
321			return common.ErrMsg{Err: err}
322		}
323		return unlinkedKeyMsg(m.index)
324	}
325}
326
327func (m *Model) deleteAccount() tea.Cmd {
328	return func() tea.Msg {
329		id := m.keys[m.getSelectedIndex()].UserID
330		m.shared.Logger.Info("user requested account deletion", "user", m.shared.User.Name, "id", id)
331		err := m.shared.Dbpool.RemoveUsers([]string{id})
332		if err != nil {
333			return common.ErrMsg{Err: err}
334		}
335		return unlinkedKeyMsg(m.index)
336	}
337}
338
339// Utils
340
341func min(a, b int) int {
342	if a < b {
343		return a
344	}
345	return b
346}
347
348func max(a, b int) int {
349	if a > b {
350		return a
351	}
352	return b
353}