diff --git a/.gitea/workflows/go.yaml b/.gitea/workflows/go.yaml index f929769..ff668cd 100644 --- a/.gitea/workflows/go.yaml +++ b/.gitea/workflows/go.yaml @@ -22,8 +22,10 @@ jobs: - name: Create dummy .env file run: | - echo "GOOGLE_CLIENT_ID=dummy_client_id" > .env - echo "GOOGLE_CLIENT_SECRET=dummy_client_secret" >> .env + echo "CLI_GOOGLE_CLIENT_ID=dummy_client_id" > .env + echo "CLI_GOOGLE_CLIENT_SECRET=dummy_client_secret" >> .env + echo "WEB_GOOGLE_CLIENT_ID=dummy_client_id" >> .env + echo "WEB_GOOGLE_CLIENT_SECRET=dummy_client_secret" >> .env - name: Build run: go build -v ./... diff --git a/README.md b/README.md index 8c1de24..25fcd8f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NotePlace Backend Server -![Go](https://img.shields.io/badge/Go-1.16%2B-00ADD8?style=for-the-badge&logo=go) +![Go](https://img.shields.io/badge/Go-1.25%2B-00ADD8?style=for-the-badge&logo=go) ![Gin Framework](https://img.shields.io/badge/Gin-Web%20Framework-008080?style=for-the-badge&logo=go) ![Google OAuth](https://img.shields.io/badge/Google%20OAuth-Authentication-4285F4?style=for-the-badge&logo=google) diff --git a/cmd/server.go b/cmd/server.go index dbb383f..56978b7 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -54,8 +54,15 @@ func main() { if err != nil { zap.S().Fatalf("Failed to initialize authenticator: %v", err) } - authHandler := &api.AuthHandler{Store: s, Authenticator: authenticator} + // Get Frontend URL from environment + frontendURL := os.Getenv("FRONTEND_URL") + if frontendURL == "" { + frontendURL = "http://localhost:5173" + } + authHandler := &api.AuthHandler{Store: s, Authenticator: authenticator, FrontendURL: frontendURL} + router.GET("/api/auth/google/login", authHandler.HandleGoogleLogin) + router.GET("/api/auth/google/callback", authHandler.HandleGoogleCallback) router.GET("/api/auth/google/cli/login", authHandler.HandleCliLogin) router.POST("/api/auth/google/cli/callback", authHandler.HandleCliCallback) router.GET("/api/auth/user", api.AuthMiddleware(s), authHandler.HandleGetUser) diff --git a/docs/FRONTEND_DESIGN.md b/docs/FRONTEND_DESIGN.md new file mode 100644 index 0000000..46261ec --- /dev/null +++ b/docs/FRONTEND_DESIGN.md @@ -0,0 +1,315 @@ +# Frontend Design Document - Noteplace + +## 1. Introduction +This document outlines the frontend design for the Noteplace application, focusing on user experience, functionality, and technical considerations for a modern, cross-platform web application with a native-like mobile experience. + +## 2. Core Principles +* **User-Centric Design:** Intuitive, easy-to-use interface. +* **Responsive & Adaptive:** Seamless experience across desktop and mobile devices. +* **Performance:** Fast loading times and smooth interactions. +* **Modern UI/UX:** Clean, aesthetically pleasing, and consistent design. +* **Progressive Web App (PWA):** Native-like capabilities on mobile, especially for iOS "Add to Home Screen." + +## 3. Technology Stack (Proposed) +* **Framework:** React (with TypeScript) for robust component-based development. +* **Styling:** Tailwind CSS for utility-first styling, enabling rapid UI development and responsiveness. Material Design principles will guide the overall aesthetic. +* **State Management:** React Context API or Zustand for simple, scalable state management. +* **Routing:** React Router for declarative navigation. +* **API Communication:** Fetch API or Axios for interacting with the backend. +* **Mapping:** Google Maps JavaScript API for interactive maps and place search. + +## 4. Frontend Functionalities + +### 4.1. User Authentication +* **Login/Signup:** Integration with Google OAuth (as per `internal/auth/google.go`). +* **Session Management:** Secure handling of user sessions. + +### 4.2. Place Management +* **Create Place:** + * User inputs place name and category. + * **Google Maps Integration:** As the user types, suggest real places using Google Places Autocomplete API. + * Upon selection, populate address fields (`Street`, `City`, `State`, `Zip`, `Country`) from Google Places details. + * Optionally, display a map preview of the selected location. + * Submit to backend to create the `Place` entry. +* **View Place Details:** Display place name, category, address, and a list of associated ratings and attributes. +* **Search Places:** Allow users to search for existing places by name, category, or address components. + +### 4.3. Rating Management +* **Add Rating:** + * Accessed from a Place's detail view. + * User provides a score (1-10) and an optional comment. + * **Attribute Integration:** + * Frontend will fetch relevant attributes for the place using `GET /api/places/:id/attributes`. + * Display these existing attributes (e.g., "Ambiance" for a "Restaurant"). + * Allow users to search for and select existing attributes. + * If an attribute doesn't exist, provide an option to create it (see 4.4. Create Attribute). The newly created attribute can then be selected for the rating. + * Submitting a rating will call `POST /api/ratings` with `placeId`, `attributeId`, `score`, and `comment`. +* **View Ratings:** + * For a specific place, ratings will be fetched using `GET /api/places/:id/ratings`. + * For the current user, ratings will be fetched using `GET /api/ratings/my-ratings`. + * Display individual ratings with score, comment, associated attribute, and user. +* **Edit/Delete Rating:** + * Allow users to edit their own ratings, calling `PUT /api/ratings/:id` to update `score` and `comment`. + * Allow users to delete their own ratings, calling `DELETE /api/ratings/:id`. + +### 4.4. Attribute Management +* **Search Attributes:** + * While creating a rating, users can search for existing attributes. This will likely involve a frontend-side search/filter of attributes already fetched or a dedicated backend search endpoint (if available, otherwise a comprehensive `GET /api/attributes` would be needed, which is not currently defined in `DESIGN.md`). For now, assume attributes are fetched per place or a general list is available. +* **Create Attribute:** + * When an attribute doesn't exist during rating creation, the user can define a new one. + * The frontend will prompt for `Name`, `Scope` (CATEGORY or PLACE), and `OwnerID` (e.g., `placeCategory` for `CATEGORY` scope, or `placeId` for `PLACE` scope). + * This will call `POST /api/attributes` to create the new attribute. + +### 4.5. User Profile +* **View Profile:** Display user's `DisplayName`, `Email`, `Role`, and `CreatedAt`. +* **Edit Profile:** (Future consideration) Allow users to update display name or other non-critical information. +* **User's Activity:** Show a summary of places created, ratings given, etc. + +## 5. Pages and Views + +### 5.1. Authentication Views +* **Login Page:** Simple page with "Sign in with Google" button. +* **Loading/Redirect Page:** For OAuth flow. + +### 5.2. Main Application Views +* **Dashboard/Home Page:** + * Overview of recent activity (e.g., recently rated places, trending attributes). + * Quick access to "Create New Place" and "Search Places." +* **Place List/Search Results Page:** + * Displays a list of places, filterable and sortable. + * Each item shows key information (name, category, address snippet). + * Should have map and list views. +* **Place Detail Page:** + * Prominent display of place information. + * Section for "Ratings" (list of all ratings for this place). + * Button to "Add New Rating." + * Section for "Attributes" (list of attributes associated with this place/category). +* **Create/Edit Place Form:** Guided form for adding new places, with Google Maps integration. +* **Add Rating Form:** Form for submitting a rating, including attribute selection/creation. +* **User Profile Page:** Displays user details and activity. + +## 6. Desktop and Mobile Support + +### 6.1. Responsive Design +* The UI will be built using a mobile-first approach, ensuring optimal layout and usability on small screens. +* Tailwind CSS will be used to implement responsive breakpoints, adapting layouts, font sizes, and component visibility for larger screens (tablets, desktops). + +### 6.2. Mobile-Specific Enhancements +* **Touch-Friendly Interactions:** Larger tap targets, swipe gestures where appropriate. +* **Optimized Navigation:** Bottom navigation bar for primary actions on mobile. +* **Performance:** Lazy loading of components and data, image optimization. + +### 6.3. iOS "Add to Home Screen" (PWA) +To provide a native-like experience on iOS when users add the web application to their home screen, the following PWA features will be implemented: +* **Web App Manifest:** A `manifest.json` file will define the app's name, icons, start URL, display mode (`standalone`), and theme colors. +* **Service Worker:** A service worker will be registered to enable: + * **Offline Capabilities:** Caching essential assets and data for offline access. + * **Faster Loading:** Instant loading on subsequent visits. + * **Push Notifications:** (Future consideration) If backend supports it. +* **Meta Tags:** Specific `` tags in the HTML head will configure iOS behavior, such as `apple-mobile-web-app-capable`, `apple-mobile-web-app-status-bar-style`, and `apple-mobile-web-app-title`. +* **Splash Screen:** Custom splash screens will be configured via meta tags and manifest for a branded launch experience. + +## 7. UI/UX Modernity +* **Clean Typography:** Use modern, readable sans-serif fonts. +* **Consistent Color Palette:** A well-defined primary, secondary, and accent color palette, adhering to accessibility standards. +* **Intuitive Iconography:** Use a consistent icon set (e.g., Material Icons). +* **Subtle Animations & Transitions:** Enhance user feedback and perceived performance. +* **Card-Based Layouts:** For displaying lists of places, ratings, and attributes, providing clear visual separation. +* **Form Design:** Clear labels, input validation feedback, and accessible controls. +* **Dark Mode:** (Future consideration) Offer a dark theme option. + +## 8. Backend API Reference for Frontend +This section provides a detailed overview of the backend API endpoints, including their expected request and response structures, to serve as a clear contract for frontend development. + +### 8.1. Data Models (JSON Representation) +These are the core data structures as they will be sent/received in JSON format. + +```json +// User +{ + "id": "string", + "googleId": "string", + "displayName": "string", + "email": "string", + "role": "number", // 0: ADMIN, 1: TRUSTED, 2: USER + "createdAt": "string" // ISO 8601 format +} + +// Session +{ + "id": "string", + "userId": "string", + "createdAt": "string", + "updatedAt": "string" +} + +// Address +{ + "street": "string", + "city": "string", + "state": "string", + "zip": "string", + "country": "string" +} + +// Place +{ + "id": "string", + "name": "string", + "category": "string", + "address": { + "street": "string", + "city": "string", + "state": "string", + "zip": "string", + "country": "string" + }, + "createdAt": "string" // ISO 8601 format +} + +// Attribute +{ + "id": "string", + "name": "string", + "scope": "number", // 0: CATEGORY, 1: PLACE + "ownerID": "string", // Category ID if scope is CATEGORY, Place ID if scope is PLACE + "description": "string", // Optional + "attachment": "string" // Optional +} + +// Rating +{ + "id": "string", + "placeId": "string", + "attributeId": "string", + "userId": "string", + "score": "number", // 1-10 + "comment": "string", // Optional + "createdAt": "string" // ISO 8601 format +} +``` + +### 8.2. Authentication Endpoints + +#### `GET /api/auth/google/cli/login` +* **Description:** Initiates the CLI login process. +* **Request:** No body. +* **Response (200 OK):** + ```json + { + "verification_url": "string", + "user_code": "string" + } + ``` + +#### `POST /api/auth/logout` +* **Description:** Logs the user out (invalidates the session). +* **Request:** No body. +* **Headers:** `Authorization: ` +* **Response (200 OK):** No body. + +### 8.3. Place Endpoints + +#### `GET /api/places` +* **Description:** Retrieves a list of all places. +* **Request:** No body. +* **Response (200 OK):** `Array` + +#### `POST /api/places` +* **Description:** Creates a new place. +* **Headers:** `Authorization: `, `Content-Type: application/json` +* **Request Body:** + ```json + { + "name": "string", + "category": "string", + "address": { + "street": "string", + "city": "string", + "state": "string", + "zip": "string", + "country": "string" + } + } + ``` +* **Response (201 Created):** `Place` + +#### `GET /api/places/:id` +* **Description:** Retrieves details for a single place. +* **Request:** No body. +* **Response (200 OK):** `Place` + +#### `GET /api/places/:id/ratings` +* **Description:** Retrieves all ratings for a specific place. +* **Request:** No body. +* **Response (200 OK):** `Array` + +#### `GET /api/places/:id/attributes` +* **Description:** Retrieves relevant attributes for a place (based on its category and specific place attributes). +* **Request:** No body. +* **Response (200 OK):** `Array` + +### 8.4. Rating Endpoints + +#### `POST /api/ratings` +* **Description:** Creates a new rating. +* **Headers:** `Authorization: `, `Content-Type: application/json` +* **Request Body:** + ```json + { + "placeId": "string", + "attributeId": "string", + "score": "number", // 1-10 + "comment": "string" // Optional + } + ``` +* **Response (201 Created):** `Rating` + +#### `PUT /api/ratings/:id` +* **Description:** Updates an existing rating. +* **Headers:** `Authorization: `, `Content-Type: application/json` +* **Request Body:** + ```json + { + "score": "number", // 1-10 + "comment": "string" // Optional + } + ``` +* **Response (200 OK):** `Rating` + +#### `DELETE /api/ratings/:id` +* **Description:** Deletes a rating. +* **Headers:** `Authorization: ` +* **Request:** No body. +* **Response (204 No Content):** No body. + +#### `GET /api/ratings/my-ratings` +* **Description:** Retrieves all ratings made by the authenticated user. +* **Headers:** `Authorization: ` +* **Request:** No body. +* **Response (200 OK):** `Array` + +### 8.5. Attribute Endpoints + +#### `POST /api/attributes` +* **Description:** Creates a new attribute. +* **Headers:** `Authorization: `, `Content-Type: application/json` +* **Request Body:** + ```json + { + "name": "string", + "scope": "number", // 0: CATEGORY, 1: PLACE + "ownerID": "string", // Required. Category ID if scope is CATEGORY, Place ID if scope is PLACE + "description": "string", // Optional + "attachment": "string" // Optional + } + ``` +* **Response (201 Created):** `Attribute` + +## 9. Future Considerations +* **Push Notifications:** For new ratings on followed places or attributes. +* **User Following:** Follow other users to see their ratings. +* **Image Uploads:** For places and attributes. +* **Advanced Search Filters:** More granular filtering for places and ratings. +* **Localization:** Support for multiple languages. +* **Accessibility:** Comprehensive WCAG compliance. diff --git a/internal/api/auth.go b/internal/api/auth.go index 032b588..188b32e 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -1,7 +1,6 @@ package api import ( - "log" "net/http" "time" @@ -10,17 +9,48 @@ import ( "git.pengzhan.dev/noteplace-server/internal/store" "github.com/gin-gonic/gin" "github.com/google/uuid" + "go.uber.org/zap" + "golang.org/x/oauth2" ) // AuthHandler handles the authentication API endpoints. type AuthHandler struct { Store *store.Store Authenticator *auth.Authenticator + FrontendURL string } -// HandleCliLogin provides the user with the URL to start the auth process. +// HandleGoogleLogin redirects the user to the Google consent page for the web flow. +func (h *AuthHandler) HandleGoogleLogin(c *gin.Context) { + url := h.Authenticator.GetWebAuthURL() + zap.S().Infof("Redirecting user to Google for web authentication: %s", url) + c.Redirect(http.StatusTemporaryRedirect, url) +} + +// HandleGoogleCallback handles the redirect from Google for the web flow. +func (h *AuthHandler) HandleGoogleCallback(c *gin.Context) { + code := c.Query("code") + if code == "" { + zap.S().Warn("Web auth callback received without a code.") + c.JSON(http.StatusBadRequest, gin.H{"error": "code is missing"}) + return + } + + zap.S().Info("Received web auth code, exchanging for token.") + token, err := h.Authenticator.ExchangeWebCodeForToken(code) + if err != nil { + zap.S().Errorf("Failed to exchange web code for token: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Failed to validate code"}) + return + } + + h.handleToken(c, token) +} + +// HandleCliLogin provides the user with the URL to start the auth process for the CLI flow. func (h *AuthHandler) HandleCliLogin(c *gin.Context) { - authURL := h.Authenticator.GetAuthURL() + authURL := h.Authenticator.GetCliAuthURL() + zap.S().Info("Providing CLI user with auth URL.") response := gin.H{ "message": `Please open the following URL in your browser to authorize the application.\nAfter authorizing, Google will provide a code. Paste it here to complete the login.\nExample: curl -X POST -d '{\"code\":\"YOUR_CODE_HERE\"}' http://localhost:3001/api/auth/google/cli/callback`, @@ -29,43 +59,53 @@ func (h *AuthHandler) HandleCliLogin(c *gin.Context) { c.JSON(http.StatusOK, response) } -// HandleCliCallback exchanges the code for a token and creates a session. +// HandleCliCallback exchanges the code for a token and creates a session for the CLI flow. func (h *AuthHandler) HandleCliCallback(c *gin.Context) { var reqBody struct { Code string `json:"code"` } if err := c.ShouldBindJSON(&reqBody); err != nil { + zap.S().Warnf("Invalid request body for CLI callback: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } - token, err := h.Authenticator.ExchangeCodeForToken(reqBody.Code) + zap.S().Info("Received CLI auth code, exchanging for token.") + token, err := h.Authenticator.ExchangeCliCodeForToken(reqBody.Code) if err != nil { - log.Printf("Failed to exchange code for token: %v", err) + zap.S().Errorf("Failed to exchange cli code for token: %v", err) c.JSON(http.StatusUnauthorized, gin.H{"error": "Failed to validate code"}) return } + h.handleToken(c, token) +} + +// handleToken is a helper function to handle user/session creation after getting a token. +func (h *AuthHandler) handleToken(c *gin.Context, token *oauth2.Token) { + zap.S().Info("Token acquired, fetching user info.") userInfo, err := h.Authenticator.GetUserInfo(token) if err != nil { - log.Printf("Failed to get user info: %v", err) + zap.S().Errorf("Failed to get user info: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info from Google"}) return } - // --- User and Session Logic --- user, found := h.Store.GetUserByGoogleID(userInfo.GoogleID) if !found { + zap.S().Infof("User with Google ID %s not found, creating new user.", userInfo.GoogleID) userInfo.ID = "user-" + uuid.New().String() userInfo.CreatedAt = time.Now().UTC().Format(time.RFC3339) if err := h.Store.CreateUser(userInfo); err != nil { + zap.S().Errorf("Failed to create user: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return } user = userInfo + } else { + zap.S().Infof("Found existing user %s for Google ID %s.", user.ID, user.GoogleID) } - // Create a new session and save it to the store sessionID := "session-" + uuid.New().String() now := time.Now().UTC().Format(time.RFC3339) newSession := models.Session{ @@ -75,11 +115,23 @@ func (h *AuthHandler) HandleCliCallback(c *gin.Context) { UpdatedAt: now, } if err := h.Store.CreateSession(newSession); err != nil { + zap.S().Errorf("Failed to create session: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"}) return } + zap.S().Infof("Created new session %s for user %s.", newSession.ID, user.ID) - c.JSON(http.StatusOK, gin.H{"message": "Login successful!", "session_token": newSession.ID}) + // Redirect back to the frontend for the web flow + if c.Request.Method == "GET" { + // Simple way to distinguish web callback from CLI POST + redirectURL := h.FrontendURL + "/auth/callback?token=" + newSession.ID + zap.S().Infof("Redirecting to frontend: %s", redirectURL) + c.Redirect(http.StatusTemporaryRedirect, redirectURL) + } else { + // Respond with JSON for the CLI flow + zap.S().Info("Returning session token to CLI client.") + c.JSON(http.StatusOK, gin.H{"message": "Login successful!", "session_token": newSession.ID}) + } } // HandleGetUser returns the current user based on the session token. diff --git a/internal/auth/google.go b/internal/auth/google.go index 2317fab..bf312da 100644 --- a/internal/auth/google.go +++ b/internal/auth/google.go @@ -14,44 +14,69 @@ import ( // Authenticator handles the OAuth2 flow. type Authenticator struct { - Config *oauth2.Config + WebConfig *oauth2.Config + CliConfig *oauth2.Config } // NewAuthenticator creates a new Authenticator. // It requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables. func NewAuthenticator() (*Authenticator, error) { - clientID := os.Getenv("GOOGLE_CLIENT_ID") - clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET") + cliClientID := os.Getenv("CLI_GOOGLE_CLIENT_ID") + cliclientSecret := os.Getenv("CLI_GOOGLE_CLIENT_SECRET") + webClientID := os.Getenv("WEB_GOOGLE_CLIENT_ID") + webClientSecret := os.Getenv("WEB_GOOGLE_CLIENT_SECRET") - if clientID == "" || clientSecret == "" { - return nil, fmt.Errorf("GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set") + if cliClientID == "" || cliclientSecret == "" { + return nil, fmt.Errorf("CLI_GOOGLE_CLIENT_ID and CLI_GOOGLE_CLIENT_SECRET must be set for CLI") } - config := &oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, + if webClientID == "" || webClientSecret == "" { + return nil, fmt.Errorf("WEB_GOOGLE_CLIENT_ID and WEB_GOOGLE_CLIENT_SECRET must be set for web") + } + + webConfig := &oauth2.Config{ + ClientID: webClientID, + ClientSecret: webClientSecret, + RedirectURL: "http://localhost:3001/api/auth/google/callback", // For web flow + Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}, + Endpoint: google.Endpoint, + } + + cliConfig := &oauth2.Config{ + ClientID: cliClientID, + ClientSecret: cliclientSecret, RedirectURL: "urn:ietf:wg:oauth:2.0:oob", // Special redirect URI for desktop/CLI apps Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}, Endpoint: google.Endpoint, } - return &Authenticator{Config: config}, nil + return &Authenticator{WebConfig: webConfig, CliConfig: cliConfig}, nil } -// GetAuthURL generates the URL the user must visit to authorize the application. -func (a *Authenticator) GetAuthURL() string { - // We don't need a state for this simple CLI flow, but it's good practice for web apps. - return a.Config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) +// GetWebAuthURL generates the URL for the web-based auth flow. +func (a *Authenticator) GetWebAuthURL() string { + return a.WebConfig.AuthCodeURL("state-token", oauth2.AccessTypeOffline) } -// ExchangeCodeForToken takes an authorization code and exchanges it for a token. -func (a *Authenticator) ExchangeCodeForToken(code string) (*oauth2.Token, error) { - return a.Config.Exchange(context.Background(), code) +// GetCliAuthURL generates the URL for the CLI-based auth flow. +func (a *Authenticator) GetCliAuthURL() string { + return a.CliConfig.AuthCodeURL("state-token", oauth2.AccessTypeOffline) +} + +// ExchangeWebCodeForToken takes an authorization code from the web flow and exchanges it for a token. +func (a *Authenticator) ExchangeWebCodeForToken(code string) (*oauth2.Token, error) { + return a.WebConfig.Exchange(context.Background(), code) +} + +// ExchangeCliCodeForToken takes an authorization code from the CLI flow and exchanges it for a token. +func (a *Authenticator) ExchangeCliCodeForToken(code string) (*oauth2.Token, error) { + return a.CliConfig.Exchange(context.Background(), code) } // GetUserInfo uses the token to fetch the user's profile from Google. func (a *Authenticator) GetUserInfo(token *oauth2.Token) (models.User, error) { - client := a.Config.Client(context.Background(), token) + // This client can be created from either WebConfig or CliConfig, it doesn't matter which. + client := a.WebConfig.Client(context.Background(), token) resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") if err != nil { return models.User{}, err