From a22ba382c2668ae8df9ebe4228481e4244bc2c9a Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Mon, 13 Apr 2026 13:33:19 +0200 Subject: [PATCH] feat: add ssh key management to account page --- NEXT.md | 47 +++++++++++++++++++++++++++++ api/handlers/auth.go | 13 ++++++-- api/handlers/ssh_keys.go | 58 +++++++++++++++++++++++++++++++++++ api/handlers/view.go | 4 +++ api/routes/routes.go | 9 ++++++ pkg/api/client.go | 59 ++++++++++++++++++++++++++++++++++++ pkg/database/migrate.go | 1 + pkg/models/coffee.go | 3 +- pkg/models/ssh_key.go | 28 +++++++++++++++++ pkg/tui/account.go | 65 +++++++++++++++++++++++++++++++++++++++- pkg/tui/model.go | 40 +++++++++++++++++++++++++ pkg/tui/update.go | 13 ++++++++ 12 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 NEXT.md create mode 100644 api/handlers/ssh_keys.go create mode 100644 pkg/models/ssh_key.go diff --git a/NEXT.md b/NEXT.md new file mode 100644 index 0000000..a14c6e2 --- /dev/null +++ b/NEXT.md @@ -0,0 +1,47 @@ +# Next Session + +## Feature: SSH Key Management in Account Page + +Following the same pattern as address/card management we just built, add an +"ssh keys" section to the account page so users can see and delete their saved +SSH public keys. + +### What needs doing + +1. **Backend** — check if `api/handlers/` has SSH key endpoints (list, delete). + If not, add them following the same pattern as address/card handlers. + Model is likely already in `pkg/models/` or needs adding. + +2. **`pkg/models/coffee.go`** — add `"ssh keys"` to `AccountMenuItems` + +3. **`pkg/tui/model.go`** — add state fields: + ```go + SSHKeys []models.SSHKey + SSHKeysLoaded bool + SSHKeyListFocused bool + AccountSSHKeyCursor int + AccountSSHKeyDeleting *int + ``` + Add `fetchSSHKeysCmd()` and `deleteSSHKeyCmd(id)` following the same + pattern as `fetchCardsCmd` / `deleteCardCmd`. + +4. **`pkg/tui/account.go`** — add `case "ssh keys":` in `BuildAccountView` + showing each key's fingerprint/comment, and handle j/k, x, y/n, esc in + `AccountUpdate` — copy the addresses block exactly. + +5. **`pkg/tui/update.go`** — fetch SSH keys when switching to account page + (same as orders). + +### After that — API Tokens +Same pattern again but for API access tokens. Reference implementation is in +`terminal-shop-source/packages/go/pkg/tui/tokens.go`. Tokens have a "create" +action in addition to delete (generates a new token and shows it once). + +### Staging environment (deferred) +Full setup guide is in the conversation history. Short version: +- Create `staging` git branch +- `docker-compose.staging.yml` already written and committed +- On server: clone repo into `~/terminalShop-staging`, create `.env.staging`, + run `docker compose -f docker-compose.staging.yml --project-name staging up -d` +- Add `staging-api.sshops.uk` to Caddyfile +- Add `host.docker.internal:host-gateway` to Caddy in `docker-compose.prod.yml` diff --git a/api/handlers/auth.go b/api/handlers/auth.go index 8b2a222..db16df1 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -72,7 +72,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) { } user = models.User{ SSHKeyFingerprint: req.Fingerprint, - SSHPublicKey: req.SSHPublicKey, + SSHPublicKey: req.SSHPublicKey, } if err := db.Create(&user).Error; err != nil { utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil) @@ -83,6 +83,15 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) { return } + if req.SSHPublicKey != "" { + sshKey := models.SSHKey{ + UserID: user.ID, + Fingerprint: req.Fingerprint, + PublicKey: req.SSHPublicKey, + } + db.Where(models.SSHKey{Fingerprint: req.Fingerprint}).FirstOrCreate(&sshKey) + } + token, err := h.jwtManager.GenerateToken(user.ID, user.Email, user.Name) if err != nil { utils.RespondError(w, http.StatusInternalServerError, "TOKEN_ERROR", "failed to generate token", nil) @@ -93,7 +102,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) { "access_token": token, "token_type": "Bearer", "expires_in": 1800, // 30 minutes in seconds - "user": user.ToPublic(), + "user": user.ToPublic(), }) } diff --git a/api/handlers/ssh_keys.go b/api/handlers/ssh_keys.go new file mode 100644 index 0000000..2de4601 --- /dev/null +++ b/api/handlers/ssh_keys.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "net/http" + "strconv" + "terminalShop/api/middleware" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" + + "github.com/go-chi/chi/v5" +) + +type SSHKeyHandler struct{} + +func NewSSHKeyHandler() *SSHKeyHandler { + return &SSHKeyHandler{} +} + +// GetSSHKeys returns all ssh keys for the authenticated user. +func (h *SSHKeyHandler) GetSSHKeys(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + var keys []models.SSHKey + if err := db.Where("user_id = ?", userID).Order("created_at ASC").Find(&keys).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to fetch ssh keys", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "ssh_keys": keys, + }) +} + +// DeleteSSHKeys removes one of the users ssh keys by ID +func (h *SSHKeyHandler) DeleteSSHKeys(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userId := middleware.UserIDFromContext(r.Context()) + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid ssh key id", nil) + return + } + + var key models.SSHKey + if err := db.Where("id = ? AND user_id = ?", id, userId).First(&key).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "SSH_KEY_NOT_FOUND", "ssh key not found", nil) + return + } + db.Delete(&key) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "message": "ssh key deleted", + }) +} diff --git a/api/handlers/view.go b/api/handlers/view.go index 9727379..3245eb2 100644 --- a/api/handlers/view.go +++ b/api/handlers/view.go @@ -37,6 +37,9 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) { var cards []models.Card db.Where("user_id = ?", userID).Find(&cards) + var sshKeys []models.SSHKey + db.Where("user_id = ?", userID).Order("created_at ASC").Find(&sshKeys) + var orders []models.Order db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders) var cartItems []models.CartItem @@ -50,6 +53,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) { "cart": cartItems, "addresses": addresses, "cards": cards, + "ssh_keys": sshKeys, "orders": orders, }) } diff --git a/api/routes/routes.go b/api/routes/routes.go index e57c00c..fa9cb62 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -43,6 +43,7 @@ func SetupRoutes( addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) viewHandler := handlers.NewViewHandler() webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey) + sshKeyHandler := handlers.NewSSHKeyHandler() // Short payment redirect and success page — no auth required. r.Get("/pay/{token}", handlers.PayRedirect) @@ -114,6 +115,14 @@ func SetupRoutes( r.Delete("/{id}", addressHandler.DeleteAddress) r.Put("/{id}/default", addressHandler.SetDefaultAddress) }) + + // SSH Keys + r.Route("/ssh-keys", func(r chi.Router){ + // GET doesnt need id because it gets every ssh key + r.Get("/", sshKeyHandler.GetSSHKeys) + // Delete needs an id to delete only one key + r.Delete("/{id}", sshKeyHandler.DeleteSSHKeys) + }) }) }) diff --git a/pkg/api/client.go b/pkg/api/client.go index 8b195ca..2b440e7 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -628,6 +628,64 @@ func (c *Client) DeleteCard(id uint) error { return nil } +type SSHKeyResponse struct { + Success bool `json:"success"` + Data struct { + SSHKeys []models.SSHKey `json:"ssh_keys"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +func (c *Client) GetSSHKeys() ([]models.SSHKey, error) { + url := fmt.Sprintf("%s/api/v1/ssh-keys", c.BaseURL) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.doRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch ssh keys: %w", err) + } + defer resp.Body.Close() + + var keysResp SSHKeyResponse + if err := json.NewDecoder(resp.Body).Decode(&keysResp); err != nil { + return nil, fmt.Errorf("failed to decode ssh keys response: %w", err) + } + + if !keysResp.Success { + if keysResp.Error != nil { + return nil, fmt.Errorf("%s: %s", keysResp.Error.Code, keysResp.Error.Message) + } + return nil, fmt.Errorf("failed to fetch ssh keys") + } + return keysResp.Data.SSHKeys, nil + +} + +func (c *Client) DeleteSSHKey(id uint) error { + url := fmt.Sprintf("%s/api/v1/ssh-keys/%d", c.BaseURL, id) + + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return fmt.Errorf("failed to create delete request: %w", err) + } + + resp, err := c.doRequest(req) + if err != nil { + return fmt.Errorf("failed to delete ssh key: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("delete ssh key failed with status %d", resp.StatusCode) + } + + return nil +} + type OrderResponse struct { Success bool `json:"success"` Data struct { @@ -784,6 +842,7 @@ type ViewInitData struct { Cart []CartItemData `json:"cart"` Addresses []models.Address `json:"addresses"` Cards []models.Card `json:"cards"` + SSHKeys []models.SSHKey `json:"ssh_keys"` Orders []models.Order `json:"orders"` } diff --git a/pkg/database/migrate.go b/pkg/database/migrate.go index 44a838e..dc7b2a4 100644 --- a/pkg/database/migrate.go +++ b/pkg/database/migrate.go @@ -23,6 +23,7 @@ func Migrate(db *gorm.DB) error { &models.Order{}, // Completed purchases &models.OrderItem{}, // Line items within orders &models.Address{}, // Saved addresses + &models.SSHKey{}, // One of the users ssh keys ); err != nil { return fmt.Errorf("migration failed: %w", err) } diff --git a/pkg/models/coffee.go b/pkg/models/coffee.go index e1cd904..7ee5340 100644 --- a/pkg/models/coffee.go +++ b/pkg/models/coffee.go @@ -26,11 +26,12 @@ func (Coffee) TableName() string { return "coffees" } -// AccountMenuItems +// AccountMenuItems var AccountMenuItems = []string{ "order history", "addresses", "cards", + "ssh keys", "faq", "about", } diff --git a/pkg/models/ssh_key.go b/pkg/models/ssh_key.go new file mode 100644 index 0000000..3f275ec --- /dev/null +++ b/pkg/models/ssh_key.go @@ -0,0 +1,28 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// SSHKey represents one of a users registered SSH public keys. +// A user may have multiple keys, any of them grants access. +type SSHKey struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Fingerprint string `gorm:"uniqueIndex;size:100;not null" json:"fingerprint"` + PublicKey string `gorm:"type:text" json:"-"` + Comment string `gorm:"size:255" json:"comment"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Associations + User User `gorm:"foreignKey:UserID" json:"-"` +} + +// TableName specifies the table name for the SSHKey model. +func (SSHKey) TableName() string { + return "ssh_keys" +} diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 3bc0476..1a3dfb7 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -127,6 +127,39 @@ func (m Model) BuildAccountView(availableHeight int) string { lines += "\n" + m.theme.TextDim().Render("enter: manage") } detailView = lines + + case "ssh keys": + lines := titleStyle.Render("SSH keys") + "\n\n" + if !m.SSHKeysLoaded { + lines += contentStyle.Render("Loading ssh keys...") + } else if len(m.SSHKeys) == 0 { + lines += contentStyle.Render("No saved ssh keys.") + } else { + for i, key := range m.SSHKeys { + isSelected := m.SSHKeyListFocused && i == m.AccountSSHKeyCursor + label := key.Fingerprint + if key.Comment != "" { + label += " " + key.Comment + } + if len(label) > detailContentWidth-4 { + label = label[:detailContentWidth-4] + } + if m.AccountSSHKeyDeleting != nil && *m.AccountSSHKeyDeleting == i { + lines += m.theme.TextError().Bold(true).Render(" delete? (y/n)") + "\n" + } else if isSelected { + lines += m.theme.TextAccent().Bold(true).Render("> "+label) + "\n" + } else { + lines += contentStyle.Render(" "+label) + "\n" + } + } + } + if m.SSHKeyListFocused { + lines += "\n" + m.theme.TextDim().Render("x: delete esc: back") + } else { + lines += "\n" + m.theme.TextDim().Render("enter: manage") + } + detailView = lines + case "faq": questionStyle := m.theme.TextHighlight().Bold(true) @@ -439,6 +472,10 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { if m.AccountCardCursor > 0 { m.AccountCardCursor-- } + } else if m.SSHKeyListFocused && m.AccountSSHKeyDeleting == nil { + if m.AccountSSHKeyCursor > 0 { + m.AccountSSHKeyCursor-- + } } else if m.FaqFocused { m.ScrollOffset -= 3 if m.ScrollOffset < 0 { @@ -468,6 +505,10 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { if m.AccountCardCursor < len(m.SavedCards)-1 { m.AccountCardCursor++ } + } else if m.SSHKeyListFocused && m.AccountSSHKeyDeleting == nil { + if m.AccountSSHKeyCursor < len(m.SSHKeys)-1 { + m.AccountSSHKeyCursor++ + } } else if m.FaqFocused { m.ScrollOffset += 3 maxScroll := m.computeFaqScrollMax() @@ -491,7 +532,7 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { if targetBottom > m.ScrollOffset+viewportHeight { m.ScrollOffset = targetBottom - viewportHeight } - } else if m.OrderViewState == 0 && !m.AddressListFocused && !m.CardListFocused && m.AccountCursor < len(models.AccountMenuItems)-1 { + } else if m.OrderViewState == 0 && !m.AddressListFocused && !m.CardListFocused && !m.SSHKeyListFocused && m.AccountCursor < len(models.AccountMenuItems)-1 { m.AccountCursor++ m.ScrollOffset = 0 } @@ -518,6 +559,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.AccountAddressDeleting = &m.AccountAddressCursor } else if m.CardListFocused && m.AccountCardDeleting == nil && m.AccountCardCursor < len(m.SavedCards) { m.AccountCardDeleting = &m.AccountCardCursor + } else if m.SSHKeyListFocused && m.AccountSSHKeyDeleting == nil && m.AccountSSHKeyCursor < len(m.SSHKeys) { + m.AccountSSHKeyDeleting = &m.AccountSSHKeyCursor } case "y": @@ -539,11 +582,21 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.AccountCardCursor-- } return m, m.deleteCardCmd(card.ID) + } else if m.AccountSSHKeyDeleting != nil { + cursor := *m.AccountSSHKeyDeleting + key := m.SSHKeys[cursor] + m.AccountSSHKeyDeleting = nil + m.SSHKeys = append(m.SSHKeys[:cursor], m.SSHKeys[cursor+1:]...) + if m.AccountSSHKeyCursor >= len(m.SSHKeys) && m.AccountSSHKeyCursor > 0 { + m.AccountSSHKeyCursor-- + } + return m, m.deleteSSHKeyCmd(key.ID) } case "n": m.AccountAddressDeleting = nil m.AccountCardDeleting = nil + m.AccountSSHKeyDeleting = nil case "p", "enter": switch selectedItem { @@ -558,6 +611,11 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { m.ScrollOffset = 0 } } + case "ssh keys": + if !m.SSHKeyListFocused { + m.SSHKeyListFocused = true + m.AccountSSHKeyCursor = 0 + } case "addresses": if !m.AddressListFocused { m.AddressListFocused = true @@ -586,6 +644,11 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { } else if m.CardListFocused { m.CardListFocused = false m.AccountCardCursor = 0 + } else if m.AccountSSHKeyDeleting != nil { + m.AccountSSHKeyDeleting = nil + } else if m.SSHKeyListFocused { + m.SSHKeyListFocused = false + m.AccountSSHKeyCursor = 0 } else if m.FaqFocused { m.FaqFocused = false m.ScrollOffset = 0 diff --git a/pkg/tui/model.go b/pkg/tui/model.go index dc7147e..8a54c1c 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -140,6 +140,13 @@ type Model struct { AccountAddressDeleting *int // index of address pending deletion AccountCardDeleting *int // index of card pending deletion + // Account SSH key state + SSHKeys []models.SSHKey // list of keys + SSHKeysLoaded bool // true when ssh keys are loaded + SSHKeyListFocused bool // true when browsing ssh keys in account + AccountSSHKeyCursor int // cursor within ssh key list in account + AccountSSHKeyDeleting *int // index of ssh key pending deletion + // Splash screen splashDataReady bool // true when products have loaded splashDelayDone bool // true when minimum display time has elapsed @@ -285,6 +292,8 @@ func (m Model) resetPageState() Model { m.CardListFocused = false m.AccountAddressDeleting = nil m.AccountCardDeleting = nil + m.SSHKeyListFocused = false + m.AccountSSHKeyDeleting = nil return m } @@ -390,6 +399,17 @@ type CardDeletedMsg struct { Err error } +// SSHKeysMsg is sent when ssh keys are fetched from the API +type SSHKeysMsg struct { + Keys []models.SSHKey + Err error +} + +// SSHKeyDeletedMsg is sent when an ssh key is deleted +type SSHKeyDeletedMsg struct { + Err error +} + func (m Model) fetchAddressesCmd() tea.Cmd { return func() tea.Msg { if m.APIClient == nil || m.User == nil { @@ -452,6 +472,26 @@ func (m Model) deleteCardCmd(id uint) tea.Cmd { } } +func (m Model) fetchSSHKeysCmd() tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil || m.User == nil { + return SSHKeysMsg{Err: fmt.Errorf("not authenticated")} + } + keys, err := m.APIClient.GetSSHKeys() + return SSHKeysMsg{Keys: keys, Err: err} + } +} + +func (m Model) deleteSSHKeyCmd(id uint) tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil || m.User == nil { + return SSHKeyDeletedMsg{Err: fmt.Errorf("not authenticated")} + } + err := m.APIClient.DeleteSSHKey(id) + return SSHKeyDeletedMsg{Err: err} + } +} + // checkoutWithSavedCard sets address and saved card on the cart, then converts. // Used when the user selects an existing card from the list (no tokenization needed). func (m Model) checkoutWithSavedCard() tea.Cmd { diff --git a/pkg/tui/update.go b/pkg/tui/update.go index da10e93..08fb343 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -89,6 +89,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loadCartFromAPI(cart) m.SavedAddresses = msg.Data.Addresses m.SavedCards = msg.Data.Cards + m.SSHKeys = msg.Data.SSHKeys + m.SSHKeysLoaded = true m.Orders = msg.Data.Orders m.OrdersLoaded = true m.splashDataReady = true @@ -133,6 +135,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ErrorMsg = fmt.Sprintf("failed to delete card: %v", msg.Err) } return m, nil + case SSHKeysMsg: + if msg.Err == nil { + m.SSHKeys = msg.Keys + m.SSHKeysLoaded = true + } + return m, nil + case SSHKeyDeletedMsg: + if msg.Err != nil { + m.ErrorMsg = fmt.Sprintf("failed to delete ssh key: %v", msg.Err) + } + return m, nil case DelayCompleteMsg: m.splashDelayDone = true if m.currentPage == splashPage && m.splashDataReady {