From 296c052fa7923eba16bbc0b03318bfb3cb74e291 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Fri, 27 Mar 2026 11:34:34 +0100 Subject: [PATCH] refactor: move SSH auth and initial data loading to splash screen previously main.go handled auth directly, it connected to the database, ran migrations, created a JWT manager and SSHAuthService, and authenticated the SSH key before the TUI even started. this commit rips all of that out Auth now happens inside the TUI during the splash screen via splashAuthCmd, which calls the /auth/token endpoint. if no user exists or the fingerprint it auto-creates one using the SSH public ke, so there is no longer a separate registartion flow. The token and user com back in the response. once authed splashViewInitCmd fires a single /view/init request that loads everything needed to start the app, products, cart, addresses, cards, and orders, before switching to the shop page on the model side, User/*models.user and gossh.PublicKey were replaced with plain Fingerprint and SSHPublicKeyStr strings, and NewModelWithAuth was updated too --- api/handlers/auth.go | 29 +++++++++++++++---- api/handlers/view.go | 51 ++++++++++++++++++++++++++++++++++ api/routes/routes.go | 3 ++ main.go | 45 ++++-------------------------- pkg/api/client.go | 66 +++++++++++++++++++++++++++++++++++--------- pkg/tui/model.go | 54 ++++++++++++------------------------ pkg/tui/splash.go | 39 +++++++++++++++++++++++++- pkg/tui/update.go | 49 ++++++++++++++++++++++++-------- 8 files changed, 229 insertions(+), 107 deletions(-) create mode 100644 api/handlers/view.go diff --git a/api/handlers/auth.go b/api/handlers/auth.go index eade900..8b2a222 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -8,6 +8,8 @@ import ( "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" + + "gorm.io/gorm" ) // AuthHandler handles authentication-related requests @@ -27,14 +29,16 @@ func NewAuthHandler(jwtManager *auth.JWTManager, authFingerprintKey string) *Aut // Type to represent a request to exchange SSH fingerprint for JWT type TokenRequest struct { Fingerprint string `json:"fingerprint"` + SSHPublicKey string `json:"ssh_public_key"` ClientSecret string `json:"client_secret"` } // Type to represent the JWT token response type TokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + User models.PublicUser `json:"user"` } func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) { @@ -60,8 +64,22 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) { // look up user by his fingies var user models.User - if err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error; err != nil { - utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil) + err := db.Where("ssh_key_fingerprint = ?", req.Fingerprint).First(&user).Error + if err == gorm.ErrRecordNotFound { + if req.SSHPublicKey == "" { + utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "no user found for this fingerprint", nil) + return + } + user = models.User{ + SSHKeyFingerprint: req.Fingerprint, + SSHPublicKey: req.SSHPublicKey, + } + if err := db.Create(&user).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create user", nil) + return + } + } else if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "database error", nil) return } @@ -75,6 +93,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(), }) } diff --git a/api/handlers/view.go b/api/handlers/view.go new file mode 100644 index 0000000..24689ef --- /dev/null +++ b/api/handlers/view.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "net/http" + + "terminalShop/api/middleware" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" +) + +type ViewHandler struct{} + +func NewViewHandler() *ViewHandler { + return &ViewHandler{} +} + +func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + var user models.User + if err := db.First(&user, userID).Error; err != nil { + utils.RespondError(w, http.StatusUnauthorized, "USER_NOT_FOUND", "user not found", nil) + return + } + + var products []models.Coffee + db.Find(&products) + + var cartItems []models.CartItem + db.Where("user_id = ?", userID).Preload("Coffee").Find(&cartItems) + + var addresses []models.Address + db.Where("user_id = ?", userID).Find(&addresses) + + var cards []models.Card + db.Where("user_id = ?", userID).Find(&cards) + + var orders []models.Order + db.Where("user_id = ?", userID).Preload("Items").Order("created_at desc").Find(&orders) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "user": user.ToPublic(), + "products": products, + "cart": cartItems, + "addresses": addresses, + "cards": cards, + "orders": orders, + }) +} diff --git a/api/routes/routes.go b/api/routes/routes.go index be3d993..b213091 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -26,6 +26,7 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan cardHandler := handlers.NewCardHandler(stripeSecretKey) orderHandler := handlers.NewOrderHandler() addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) + viewHandler := handlers.NewViewHandler() // API v1 routes r.Route("/api/v1", func(r chi.Router) { @@ -46,6 +47,8 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth) + r.Get("/view/init", viewHandler.GetViewInit) + // Cart r.Route("/cart", func(r chi.Router) { r.Get("/", cartHandler.GetCart) diff --git a/main.go b/main.go index 81898f6..f5c1fdf 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "syscall" + "time" tea "github.com/charmbracelet/bubbletea" @@ -17,8 +18,6 @@ import ( "terminalShop/pkg/auth" "terminalShop/pkg/config" - "terminalShop/pkg/database" - "terminalShop/pkg/models" "terminalShop/pkg/tui" ) @@ -28,7 +27,6 @@ const ( ) var ( - authService *auth.SSHAuthService apiURL string authFingerprintKey string ) @@ -37,28 +35,14 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { // Extract SSH public key from session pubKey := s.PublicKey() - var user *models.User - var token string - + var fingerprint, pubKeyStr string if pubKey != nil { - // Authenticate with SSH key (auto-creates user on first connect!) - authenticatedUser, jwtToken, err := authService.AuthenticateSSHKey(pubKey) - if err != nil { - log.Printf("Auth error: %v", err) - // Continue as guest - } else { - user = authenticatedUser - token = jwtToken - displayName := user.SSHKeyFingerprint[:16] - if user.Name != "" { - displayName = user.Name - } - log.Printf("User authenticated: %s (ID: %d)", displayName, user.ID) - } + fingerprint = auth.GetSSHKeyFingerprint(pubKey) + pubKeyStr = auth.FormatSSHPublicKey(pubKey) } // Create TUI model with user context (no registration screen needed!) - m := tui.NewModelWithAuth(user, false, s.PublicKey(), token, apiURL, authFingerprintKey) + m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey) return m, []tea.ProgramOption{tea.WithAltScreen()} } @@ -69,25 +53,6 @@ func main() { log.Fatalf("Failed to load config: %v", err) } - // Initialize database connection (for user management) - // Use same database as API server - db, err := database.Connect(cfg.DatabaseURL) - if err != nil { - log.Fatalf("Failed to connect to database: %v", err) - } - defer database.Close() - - // Run migrations (includes users table) - if err := database.Migrate(db); err != nil { - log.Fatalf("Failed to run migrations: %v", err) - } - - // Initialize JWT manager (uses same secret as API) - jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) - - // Initialize SSH auth service - authService = auth.NewSSHAuthService(db, jwtManager) - // Set API URL and auth key from config for TUI connections apiURL = cfg.APIURL authFingerprintKey = cfg.AuthFingerprintKey diff --git a/pkg/api/client.go b/pkg/api/client.go index 22e7570..04fb023 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -39,6 +39,7 @@ func (c *Client) doRequest(req *http.Request) (*http.Response, error) { // TokenRequest represents a request to exchange SSH fingerprint for JWT. type TokenRequest struct { Fingerprint string `json:"fingerprint"` + SSHPublicKey string `json:"ssh_public_key"` ClientSecret string `json:"client_secret"` } @@ -46,16 +47,16 @@ type TokenRequest struct { type TokenResponse struct { Success bool `json:"success"` Data struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + User models.PublicUser `json:"user"` } `json:"data"` Error *APIError `json:"error,omitempty"` } -// RefreshToken exchanges an SSH fingerprint and client secret for a new JWT. -// It does not update c.Token — the caller is responsible for that. -func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) { +func (c *Client) GetOrCreateToken(fingerprint, pubKeyStr, clientSecret string) (string, models.PublicUser, error) { + url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL) reqBody := TokenRequest{ @@ -65,35 +66,40 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) jsonData, err := json.Marshal(reqBody) if err != nil { - return "", fmt.Errorf("failed to marshal token request: %w", err) + return "", models.PublicUser{}, fmt.Errorf("failed to marshal token request: %w", err) } req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { - return "", fmt.Errorf("failed to create token request: %w", err) + return "", models.PublicUser{}, fmt.Errorf("failed to create token request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.HTTPClient.Do(req) if err != nil { - return "", fmt.Errorf("token refresh request failed: %w", err) + return "", models.PublicUser{}, fmt.Errorf("token refresh request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("token refresh returned status %d", resp.StatusCode) + return "", models.PublicUser{}, fmt.Errorf("token refresh returned status %d", resp.StatusCode) } var tokenResp TokenResponse if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - return "", fmt.Errorf("failed to decode token response: %w", err) + return "", models.PublicUser{}, fmt.Errorf("failed to decode token response: %w", err) } if !tokenResp.Success || tokenResp.Data.AccessToken == "" { - return "", fmt.Errorf("token refresh failed: no access token in response") + return "", models.PublicUser{}, fmt.Errorf("token refresh failed: no access token in response") } - return tokenResp.Data.AccessToken, nil + return tokenResp.Data.AccessToken, tokenResp.Data.User, nil +} + +func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) { + token, _, err := c.GetOrCreateToken(fingerprint, "", clientSecret) + return token, err } // APIResponse represents the standard API response format @@ -729,3 +735,37 @@ func (c *Client) DeleteAddress(id uint) error { return nil } +type ViewInitData struct { + User models.PublicUser `json:"user"` + Products []models.Coffee `json:"products"` + Cart []CartItemData `json:"cart"` + Addresses []models.Address `json:"addresses"` + Cards []models.Card `json:"cards"` + Orders []models.Order `json:"orders"` +} + +type ViewInitResponse struct { + Success bool `json:"success"` + Data ViewInitData `json:"data"` + Error *APIError `json:"error"` +} + +// GetViewInit fetches all initial data in one request +func (c *Client) GetViewInit() (ViewInitData, error) { + url := fmt.Sprintf("%s/api/v1/view/init", c.BaseURL) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return ViewInitData{}, fmt.Errorf("failed to create request: %w", err) + } + resp, err := c.doRequest(req) + if err != nil { + return ViewInitData{}, fmt.Errorf("view init request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return ViewInitData{}, fmt.Errorf("view init returned status: %d", resp.StatusCode) + } + var viewResp ViewInitResponse + + return viewResp.Data, nil +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index b6e3495..0e9ad04 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -11,10 +11,8 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" - gossh "golang.org/x/crypto/ssh" "terminalShop/pkg/api" - "terminalShop/pkg/auth" "terminalShop/pkg/models" ) @@ -53,12 +51,11 @@ const resizeDebounce = 50 * time.Millisecond type Model struct { // User authentication - User *models.User // Authenticated user (nil if not logged in) - IsNewUser bool // True if user needs to register - SSHPublicKey gossh.PublicKey // SSH public key for registration + User *models.User + Fingerprint string + SSHPublicKeyStr string AccessToken string AuthFingerprintKey string // shared secret for /auth/token refresh - UsernameInput string // Input for username during registration // Shop state Username string @@ -158,7 +155,6 @@ func (m Model) checkoutStep() int { func (m *Model) updateLayout(width, height int) { m.viewportWidth = width m.viewportHeight = height - switch { case width < 20 || height < 10: m.size = undersized @@ -222,21 +218,15 @@ func (m Model) fetchProductsCmd() tea.Msg { } func (m Model) Init() tea.Cmd { - // Fetch products and cart from API on startup, and schedule token refresh - cmds := []tea.Cmd { - m.fetchProductsCmd, - m.fetchCartCmd, + return tea.Batch( + m.splashAuthCmd, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg { return DelayCompleteMsg{} }), - tea.Tick(700*time.Microsecond, func(t time.Time) tea.Msg { + tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg { return splashCursorTickMsg{} }), - } - if m.User != nil && m.AuthFingerprintKey != "" { - cmds = append(cmds, m.scheduleTokenRefreshCmd()) - } - return tea.Batch(cmds...) + ) } func (m Model) resetPageState() Model { @@ -301,12 +291,11 @@ func (m Model) scheduleTokenRefreshCmd() tea.Cmd { // refreshTokenCmd calls the /auth/token endpoint to get a fresh JWT. func (m Model) refreshTokenCmd() tea.Cmd { return func() tea.Msg { - if m.APIClient == nil || m.User == nil || m.SSHPublicKey == nil { + if m.APIClient == nil || m.Fingerprint == "" { return TokenRefreshedMsg{Err: fmt.Errorf("not authenticated, cannot refresh token")} } - fingerprint := auth.GetSSHKeyFingerprint(m.SSHPublicKey) - token, err := m.APIClient.RefreshToken(fingerprint, m.AuthFingerprintKey) + token, err := m.APIClient.RefreshToken(m.Fingerprint, m.AuthFingerprintKey) if err != nil { log.Printf("[TUI] Token refresh failed: %v", err) return TokenRefreshedMsg{Err: err} @@ -655,7 +644,7 @@ func (m Model) GetCartItemsSlice() []*models.CartItem { // NewModel creates a new model with default coffee options func NewModel(username string) Model { m := Model{ - Username: username, + Username: username, currentPage: splashPage, Coffees: []models.Coffee{ { @@ -729,23 +718,16 @@ func NewModel(username string) Model { } // NewModelWithAuth creates a new model with user authentication context -func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey, token string, apiURL string, authFingerprintKey string) Model { - username := "guest" - if user != nil { - username = user.Name - } - - m := NewModel(username) - m.User = user - m.IsNewUser = isNewUser - m.SSHPublicKey = pubKey - m.AccessToken = token - m.AuthFingerprintKey = authFingerprintKey +func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string) Model { + m := NewModel("") + m.Fingerprint = fingerprint + m.SSHPublicKeyStr = pubKeyStr + m.AuthFingerprintKey = clientSecret + m.currentPage = splashPage if apiURL != "" { - m.APIClient = api.NewClient(apiURL, token) + m.APIClient = api.NewClient(apiURL, "") } else { - m.APIClient = api.NewClient("http://localhost:8080", token) + m.APIClient = api.NewClient("http://localhost:8000", "") } - return m } diff --git a/pkg/tui/splash.go b/pkg/tui/splash.go index d045c65..3e6358d 100644 --- a/pkg/tui/splash.go +++ b/pkg/tui/splash.go @@ -1,12 +1,49 @@ package tui import ( + "fmt" + "terminalShop/pkg/api" + "terminalShop/pkg/models" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type DelayCompleteMsg struct{} type splashCursorTickMsg struct{} +type SplashAuthMsg struct { + Token string + User models.PublicUser + Err error +} + +type ViewInitMsg struct { + Data api.ViewInitData + Err error +} + +func (m Model) splashAuthCmd() tea.Msg { + token, user, err := m.APIClient.GetOrCreateToken( + m.Fingerprint, + m.SSHPublicKeyStr, + m.AuthFingerprintKey, + ) + if err != nil { + return SplashAuthMsg{Err: fmt.Errorf("auth failed: %w", err)} + } + return SplashAuthMsg{Token: token, User: user} +} + +func (m Model) splashViewInitCmd() tea.Msg { + data, err := m.APIClient.GetViewInit() + if err != nil { + return ViewInitMsg{Err: fmt.Errorf("failed to load data: %w", err)} + } + return ViewInitMsg{Data: data} +} + func (m Model) SplashView() string { accent := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true) @@ -25,7 +62,7 @@ func (m Model) SplashView() string { ) return lipgloss.Place( - m.viewportWidth, + m.viewportWidth, m.viewportHeight, lipgloss.Center, lipgloss.Center, diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 4a7d573..8c9abe0 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -2,6 +2,8 @@ package tui import ( "fmt" + "terminalShop/pkg/api" + "terminalShop/pkg/models" "time" tea "github.com/charmbracelet/bubbletea" @@ -39,24 +41,47 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Tick(resizeDebounce, func(t time.Time) tea.Msg { return resizeTickMsg{seq: seq} }) - case ProductsMsg: - m.Loading = false + case SplashAuthMsg: if msg.Err != nil { - m.ErrorMsg = "Failed to load products from the API, using the fallback values" - } else if len(msg.Products) > 0 { - m.Coffees = msg.Products - m.ErrorMsg = "" + m.ErrorMsg = fmt.Sprintf("authentication failed: %v", msg.Err) + return m, nil } + m.AccessToken = msg.Token + m.APIClient.Token = msg.Token + if msg.User.ID != 0 { + m.Username = msg.User.Name + } + return m, tea.Batch(m.splashViewInitCmd, m.scheduleTokenRefreshCmd()) + case ViewInitMsg: + if msg.Err != nil { + m.ErrorMsg = fmt.Sprintf("failed to load: %v", msg.Err) + m.Loading = false + return m, nil + } + m.Loading = false + if len(msg.Data.Products) > 0 { + m.Coffees = msg.Data.Products + } + if msg.Data.User.ID != 0 { + u := models.User{ + ID: msg.Data.User.ID, + Name: msg.Data.User.Email, + Email: msg.Data.User.Email, + } + m.User = &u + m.Username = u.Name + } + cart := &api.CartData{Items: msg.Data.Cart} + m.loadCartFromAPI(cart) + m.SavedAddresses = msg.Data.Addresses + m.SavedCards = msg.Data.Cards + m.Orders = msg.Data.Orders + m.OrdersLoaded = true m.splashDataReady = true - if m.currentPage == splashPage && m.splashDelayDone { + if m.splashDelayDone { return m.SwitchPage(shopPage), nil } return m, nil - case CartFetchedMsg: - if msg.Err == nil && msg.Cart != nil { - m.loadCartFromAPI(msg.Cart) - } - return m, nil case CartSyncedMsg: if msg.Err != nil { return m, m.fetchCartCmd