feat: add ssh key management to account page

This commit is contained in:
2026-04-13 13:33:19 +02:00
parent 7cbefeae40
commit a22ba382c2
12 changed files with 336 additions and 4 deletions

47
NEXT.md Normal file
View File

@@ -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`

View File

@@ -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(),
})
}

58
api/handlers/ssh_keys.go Normal file
View File

@@ -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",
})
}

View File

@@ -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,
})
}

View File

@@ -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)
})
})
})

View File

@@ -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"`
}

View File

@@ -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)
}

View File

@@ -26,11 +26,12 @@ func (Coffee) TableName() string {
return "coffees"
}
// AccountMenuItems
// AccountMenuItems
var AccountMenuItems = []string{
"order history",
"addresses",
"cards",
"ssh keys",
"faq",
"about",
}

28
pkg/models/ssh_key.go Normal file
View File

@@ -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"
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {