feat: Improve OAuth handling and user data extraction in authentication flow

This commit is contained in:
M1ngdaXie
2026-01-08 16:35:38 -08:00
parent 0a5e6661f1
commit 6ba18854bf
5 changed files with 205 additions and 178 deletions

307
README.md
View File

@@ -1,241 +1,240 @@
# Real-Time Collaboration Platform # Real-Time Collaboration Platform
A full-stack real-time collaborative application supporting both text editing and Kanban boards, built with React and Go. The system uses Yjs CRDTs for conflict-free synchronization and WebSockets for real-time updates. A full-stack real-time collaborative application supporting text editing and Kanban boards. Built with React, Go, Yjs CRDTs, and WebSockets.
## Features ## Features
- **Collaborative Text Editor**: Real-time document editing with TipTap editor - **Collaborative Text Editor**: Real-time document editing with TipTap
- **Collaborative Kanban Boards**: Drag-and-drop task management - **Collaborative Kanban Boards**: Drag-and-drop task management
- **Real-Time Synchronization**: Instant updates across all connected clients - **User Authentication**: OAuth2 login (Google, GitHub)
- **Document Sharing**: Share documents with users or via public links
- **Offline Support**: IndexedDB persistence for offline editing - **Offline Support**: IndexedDB persistence for offline editing
- **User Presence**: See who's currently editing with live cursors and awareness - **User Presence**: Live cursors and user awareness
## Tech Stack ## Tech Stack
### Frontend **Frontend:** React 19, TypeScript, Vite, TipTap, Yjs, y-websocket, y-indexeddb
- React 19 + TypeScript **Backend:** Go 1.25, Gin, Gorilla WebSocket, PostgreSQL 16
- Vite (build tool) **Infrastructure:** Docker Compose
- TipTap (collaborative rich text editor)
- Yjs (CRDT for conflict-free replication)
- y-websocket (WebSocket sync provider)
- y-indexeddb (offline persistence)
### Backend ## Quick Start
- Go 1.25
- Gin (web framework)
- Gorilla WebSocket
- PostgreSQL 16 (document storage)
- Redis 7 (future use)
### Infrastructure
- Docker Compose
- PostgreSQL
- Redis
## Getting Started
### Prerequisites ### Prerequisites
- Node.js 18+, npm
- Node.js 18+ and npm
- Go 1.25+ - Go 1.25+
- Docker and Docker Compose - Docker & Docker Compose
### Installation ### Setup
1. **Clone the repository** 1. **Clone and configure environment**
```bash ```bash
git clone <your-repo-url> git clone <repo-url>
cd realtime-collab cd realtime-collab
```
2. **Set up environment variables** # Setup environment files
Create backend environment file:
```bash
cp backend/.env.example backend/.env
# Edit backend/.env with your configuration
```
Create frontend environment file:
```bash
cp frontend/.env.example frontend/.env
# Edit frontend/.env with your configuration (optional for local development)
```
Create root environment file for Docker:
```bash
cp .env.example .env cp .env.example .env
# Edit .env with your Docker database credentials cp backend/.env.example backend/.env
# Edit .env files with your configuration
# Minimum required: DATABASE_URL, JWT_SECRET
``` ```
3. **Start the infrastructure** 2. **Start infrastructure**
```bash ```bash
docker-compose up -d docker-compose up -d
``` ```
This will start PostgreSQL and Redis containers. 3. **Run backend**
4. **Install and run the backend**
```bash ```bash
cd backend cd backend
go mod download go mod download
go run cmd/server/main.go go run cmd/server/main.go
# Server runs on http://localhost:8080
``` ```
The backend server will start on `http://localhost:8080` 4. **Run frontend**
5. **Install and run the frontend**
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev npm run dev
``` # App runs on http://localhost:5173
The frontend will start on `http://localhost:5173`
6. **Access the application**
Open your browser and navigate to `http://localhost:5173`
## Environment Variables
### Backend (backend/.env)
```env
PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/collaboration?sslmode=disable
REDIS_URL=redis://localhost:6379
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
```
### Frontend (frontend/.env)
```env
VITE_API_URL=http://localhost:8080/api
VITE_WS_URL=ws://localhost:8080/ws
```
### Docker Compose (.env)
```env
POSTGRES_USER=collab
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=collaboration
``` ```
## Architecture ## Architecture
### System Overview
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Browser │◄────────►│ Go Backend │◄────────►│ PostgreSQL │
│ │ │ │ │ │
│ React + Yjs │ WS+HTTP │ Gin + Hub │ SQL │ Documents │
│ TipTap │ │ WebSocket │ │ Users │
│ IndexedDB │ │ REST API │ │ Sessions │
└─────────────┘ └──────────────┘ └─────────────┘
```
### Real-Time Collaboration Flow ### Real-Time Collaboration Flow
```
Client A Backend Hub Client B
│ │ │
│──1. Edit document──────────►│ │
│ (Yjs binary update) │ │
│ │──2. Broadcast update────►│
│ │ │
│ │◄─3. Edit document────────│
│◄─4. Broadcast update───────│ │
│ │ │
▼ ▼ ▼
[Apply CRDT merge] [Relay only] [Apply CRDT merge]
```
1. Client connects to WebSocket endpoint `/ws/:documentId` ### Authentication Flow
2. WebSocket handler creates a client and registers it with the Hub ```
3. Hub manages rooms (one room per document) User OAuth Provider Backend Database
4. Yjs generates binary updates on document changes │ │ │ │
5. Client sends updates to WebSocket │──1. Click login───────►│ │ │
6. Hub broadcasts updates to all clients in the same room │◄─2. Auth page──────────│ │ │
7. Yjs applies updates and merges changes automatically (CRDT) │──3. Approve───────────►│ │ │
8. IndexedDB persists state locally for offline support │ │──4. Callback──────►│ │
│ │ │──5. Create user───►│
### API Endpoints │ │ │◄──────────────────│
│◄─6. JWT token + cookie─┤◄──────────────────│ │
#### REST API ```
- `GET /api/documents` - List all documents
- `POST /api/documents` - Create a new document
- `GET /api/documents/:id` - Get document metadata
- `GET /api/documents/:id/state` - Get document Yjs state
- `PUT /api/documents/:id/state` - Update document Yjs state
- `DELETE /api/documents/:id` - Delete a document
#### WebSocket
- `GET /ws/:roomId` - WebSocket connection for real-time sync
#### Health Check
- `GET /health` - Server health status
## Project Structure ## Project Structure
``` ```
realtime-collab/ realtime-collab/
├── backend/ ├── backend/
│ ├── cmd/server/ # Application entry point │ ├── cmd/server/ # Entry point
│ ├── internal/ │ ├── internal/
│ │ ├── auth/ # JWT & OAuth middleware
│ │ ├── handlers/ # HTTP/WebSocket handlers │ │ ├── handlers/ # HTTP/WebSocket handlers
│ │ ├── hub/ # WebSocket hub (room management) │ │ ├── hub/ # WebSocket hub & rooms
│ │ ├── models/ # Domain models │ │ ├── models/ # Domain models
│ │ └── store/ # Database layer │ │ └── store/ # PostgreSQL data layer
│ └── scripts/ # Database initialization scripts │ └── scripts/init.sql # Database schema
├── frontend/ ├── frontend/
│ ├── src/ │ ├── src/
│ │ ├── api/ # REST API client │ │ ├── api/ # REST API client
│ │ ├── components/ # React components │ │ ├── components/ # React components
│ │ ├── lib/ # Yjs integration │ │ ├── hooks/ # Custom hooks
│ │ ├── pages/ # Page components │ │ ├── lib/yjs.ts # Yjs setup
│ │ └── hooks/ # Custom React hooks │ │ └── pages/ # Page components
│ └── public/ # Static assets └── docker-compose.yml # PostgreSQL & Redis
└── docker-compose.yml # Infrastructure setup
``` ```
## Development ## Key Endpoints
### Backend Commands ### REST API
- `POST /api/auth/google` - Google OAuth login
- `POST /api/auth/github` - GitHub OAuth login
- `GET /api/auth/me` - Get current user
- `POST /api/auth/logout` - Logout
- `GET /api/documents` - List all documents
- `POST /api/documents` - Create document
- `GET /api/documents/:id` - Get document
- `PUT /api/documents/:id/state` - Update Yjs state
- `DELETE /api/documents/:id` - Delete document
- `POST /api/documents/:id/shares` - Share with user
- `POST /api/documents/:id/share-link` - Create public link
### WebSocket
- `GET /ws/:roomId` - Real-time sync endpoint
## Environment Variables
### Backend (.env)
```bash
PORT=8080
DATABASE_URL=postgres://user:pass@localhost:5432/collaboration?sslmode=disable
JWT_SECRET=your-secret-key-here
FRONTEND_URL=http://localhost:5173
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
# OAuth (optional)
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
```
### Docker (.env in root)
```bash
POSTGRES_USER=collab
POSTGRES_PASSWORD=your-secure-password
POSTGRES_DB=collaboration
```
## Development Commands
### Backend
```bash ```bash
cd backend cd backend
go run cmd/server/main.go # Run development server go run cmd/server/main.go # Run server
go test ./... # Run tests
go test -v ./internal/handlers # Run handler tests
go build -o server cmd/server/main.go # Build binary go build -o server cmd/server/main.go # Build binary
go fmt ./... # Format code
``` ```
### Frontend Commands ### Frontend
```bash ```bash
cd frontend cd frontend
npm run dev # Start dev server npm run dev # Dev server
npm run build # Production build npm run build # Production build
npm run preview # Preview production build npm run lint # Lint code
npm run lint # Run ESLint
``` ```
### Database ### Database
The database schema is automatically initialized on first run using `backend/scripts/init.sql`.
To reset the database:
```bash ```bash
docker-compose down -v docker-compose down -v # Reset database
docker-compose up -d docker-compose up -d # Start fresh
``` ```
## How It Works ## How It Works
### Conflict-Free Replication (CRDT) ### CRDT-Based Collaboration
- **Yjs** provides Conflict-free Replicated Data Types (CRDTs)
- Multiple users edit simultaneously without conflicts
- Changes merge automatically, no manual conflict resolution
- Works offline, syncs when reconnected
The application uses Yjs, a CRDT implementation, which allows: ### Backend as Message Broker
- Multiple users to edit simultaneously without conflicts - Backend doesn't understand Yjs data structure
- Automatic merging of concurrent changes - Simply broadcasts binary updates to all clients in a room
- Offline editing with eventual consistency - All conflict resolution happens client-side via Yjs
- No need for operational transformation or locking - Rooms are isolated by document ID
### WebSocket Broadcasting ### Data Flow
1. User edits → Yjs generates binary update
2. Update sent via WebSocket to backend
3. Backend broadcasts to all clients in room
4. Each client's Yjs applies and merges update
5. IndexedDB persists state locally
6. Periodic backup to PostgreSQL (optional)
The backend acts as a message broker: ## Testing
1. Receives binary Yjs updates from clients
2. Broadcasts updates to all clients in the same room
3. Does not interpret or modify the updates
4. Yjs handles all conflict resolution on the client side
## Contributing The backend uses `testify/suite` for organized testing:
```bash
go test ./internal/handlers # All handler tests
go test -v ./internal/handlers -run TestDocumentHandler # Specific suite
```
1. Fork the repository ## Database Schema
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`) - **documents**: Document metadata and Yjs state (BYTEA)
4. Push to the branch (`git push origin feature/amazing-feature`) - **users**: OAuth user profiles
5. Open a Pull Request - **sessions**: JWT tokens with expiration
- **shares**: User-to-user document sharing
- **share_links**: Public shareable links
## License ## License
This project is licensed under the MIT License. MIT License
## Acknowledgments ## Acknowledgments
- [Yjs](https://github.com/yjs/yjs) - CRDT framework - [Yjs](https://github.com/yjs/yjs) - CRDT framework
- [TipTap](https://tiptap.dev/) - Rich text editor - [TipTap](https://tiptap.dev/) - Collaborative editor
- [Gin](https://gin-gonic.com/) - Go web framework - [Gin](https://gin-gonic.com/) - Go web framework

View File

@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -67,14 +66,14 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
} }
log.Println("Google callback state:", c.Query("state")) log.Println("Google callback state:", c.Query("state"))
// Exchange code for token // Exchange code for token
token, err := h.googleConfig.Exchange(context.Background(), c.Query("code")) token, err := h.googleConfig.Exchange(c.Request.Context(), c.Query("code"))
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"})
return return
} }
// Get user info from Google // Get user info from Google
client := h.googleConfig.Client(context.Background(), token) client := h.googleConfig.Client(c.Request.Context(), token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
@@ -91,7 +90,12 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
Name string `json:"name"` Name string `json:"name"`
Picture string `json:"picture"` Picture string `json:"picture"`
} }
json.Unmarshal(data, &userInfo)
if err := json.Unmarshal(data, &userInfo); err != nil {
log.Printf("Failed to parse Google response: %v | Data: %s", err, string(data))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid Google response"})
return
}
log.Println("Google user info:", userInfo) log.Println("Google user info:", userInfo)
// Upsert user in database // Upsert user in database
user, err := h.store.UpsertUser( user, err := h.store.UpsertUser(
@@ -124,12 +128,19 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
// GithubLogin redirects to GitHub OAuth // GithubLogin redirects to GitHub OAuth
func (h *AuthHandler) GithubLogin(c *gin.Context) { func (h *AuthHandler) GithubLogin(c *gin.Context) {
url := h.githubConfig.AuthCodeURL("state", oauth2.AccessTypeOffline) oauthState := generateStateOauthCookie(c.Writer)
url := h.githubConfig.AuthCodeURL(oauthState)
c.Redirect(http.StatusTemporaryRedirect, url) c.Redirect(http.StatusTemporaryRedirect, url)
} }
// GithubCallback handles GitHub OAuth callback // GithubCallback handles GitHub OAuth callback
func (h *AuthHandler) GithubCallback(c *gin.Context) { func (h *AuthHandler) GithubCallback(c *gin.Context) {
oauthState, err := c.Cookie("oauthstate")
if err != nil || c.Query("state") != oauthState {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
return
}
log.Println("Github callback state:", c.Query("state"))
code := c.Query("code") code := c.Query("code")
if code == "" { if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No code provided"}) c.JSON(http.StatusBadRequest, gin.H{"error": "No code provided"})
@@ -137,14 +148,14 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
} }
// Exchange code for token // Exchange code for token
token, err := h.githubConfig.Exchange(context.Background(), code) token, err := h.githubConfig.Exchange(c.Request.Context(), code)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"})
return return
} }
// Get user info from GitHub // Get user info from GitHub
client := h.githubConfig.Client(context.Background(), token) client := h.githubConfig.Client(c.Request.Context(), token)
// Get user profile // Get user profile
resp, err := client.Get("https://api.github.com/user") resp, err := client.Get("https://api.github.com/user")
@@ -162,7 +173,11 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
Email string `json:"email"` Email string `json:"email"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
} }
json.Unmarshal(data, &userInfo) if err := json.Unmarshal(data, &userInfo); err != nil {
log.Printf("Failed to parse GitHub response: %v | Data: %s", err, string(data))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid GitHub response"})
return
}
// If email is not public, fetch it separately // If email is not public, fetch it separately
if userInfo.Email == "" { if userInfo.Email == "" {
@@ -188,7 +203,8 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
if userInfo.Name == "" { if userInfo.Name == "" {
userInfo.Name = userInfo.Login userInfo.Name = userInfo.Login
} }
fmt.Println("Getting user info : ")
fmt.Println(userInfo)
// Upsert user in database // Upsert user in database
user, err := h.store.UpsertUser( user, err := h.store.UpsertUser(
c.Request.Context(), c.Request.Context(),
@@ -199,7 +215,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
&userInfo.AvatarURL, &userInfo.AvatarURL,
) )
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create user: %v", err)})
return return
} }

View File

@@ -4,12 +4,15 @@ import { API_BASE_URL, authFetch } from './client';
export const authApi = { export const authApi = {
getCurrentUser: async (): Promise<User> => { getCurrentUser: async (): Promise<User> => {
const response = await authFetch(`${API_BASE_URL}/auth/me`); const response = await authFetch(`${API_BASE_URL}/auth/me`);
console.log("current user is " + response)
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to get current user'); throw new Error('Failed to get current user');
} }
return response.json(); const data = await response.json();
console.log("current user data:", data);
// Backend returns { user: {...} }, extract the user object
return data.user;
}, },
logout: async (): Promise<void> => { logout: async (): Promise<void> => {

View File

@@ -27,17 +27,25 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
const initializeDocument = async () => { const initializeDocument = async () => {
// For share token access, use placeholder user info // For share token access, use placeholder user info
const authUser = user || { // Extract user data (handle both direct user object and nested structure for backwards compat)
const realUser = user || {
id: "anonymous", id: "anonymous",
name: "Anonymous User", name: "Anonymous User",
email: "",
avatar_url: undefined, avatar_url: undefined,
}; };
const realUser = user?.user ? user.user : user || {};
const currentName = realUser.name || realUser.email || "Anonymous";
const currentId = realUser.id; const currentId = realUser.id;
const currentAvatar = realUser.avatar_url || realUser.avatar; const currentName = realUser.name || realUser.email || "Anonymous";
console.log("✅ [Fixed] User Name is:", currentName); const currentAvatar = realUser.avatar_url;
console.log("🔍 [Debug] Initializing Awareness with User:", authUser); // <--- 添加这行
console.log("🔍 [Debug] User data for awareness:", {
id: currentId,
name: currentName,
email: realUser.email,
avatar: currentAvatar,
rawUser: realUser,
});
const authToken = token || ""; const authToken = token || "";
console.log("authToken is " + token); console.log("authToken is " + token);
@@ -55,8 +63,8 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
} }
console.log( console.log(
"🔍 [Debug] Full authUser object:", "🔍 [Debug] Full user object:",
JSON.stringify(authUser, null, 2) JSON.stringify(realUser, null, 2)
); );
// Set user info for awareness with authenticated user data // Set user info for awareness with authenticated user data
yjsProviders.awareness.setLocalStateField("user", { yjsProviders.awareness.setLocalStateField("user", {
@@ -124,8 +132,8 @@ export const useYjsDocument = (documentId: string, shareToken?: string) => {
); );
// Log local user info // Log local user info
console.log(`[Awareness] Local user initialized: ${authUser.name}`, { console.log(`[Awareness] Local user initialized: ${currentName}`, {
color: getColorFromUserId(authUser.id), color: getColorFromUserId(currentId),
clientId: yjsProviders.awareness.clientID, clientId: yjsProviders.awareness.clientID,
}); });

View File

@@ -15,9 +15,10 @@ const Home = () => {
const loadDocuments = async () => { const loadDocuments = async () => {
try { try {
const { documents } = await documentsApi.list(); const { documents } = await documentsApi.list();
setDocuments(documents); setDocuments(documents || []);
} catch (error) { } catch (error) {
console.error("Failed to load documents:", error); console.error("Failed to load documents:", error);
setDocuments([]); // Set empty array on error to prevent null access
} finally { } finally {
setLoading(false); setLoading(false);
} }