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