feat: add guest mode, bug fixes, and self-hosted config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
backend/.gitignore
vendored
2
backend/.gitignore
vendored
@@ -4,7 +4,7 @@
|
|||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# Compiled binaries
|
# Compiled binaries
|
||||||
server
|
/server
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func main() {
|
|||||||
authGroup.GET("/google/callback", authHandler.GoogleCallback)
|
authGroup.GET("/google/callback", authHandler.GoogleCallback)
|
||||||
authGroup.GET("/github", authHandler.GithubLogin)
|
authGroup.GET("/github", authHandler.GithubLogin)
|
||||||
authGroup.GET("/github/callback", authHandler.GithubCallback)
|
authGroup.GET("/github/callback", authHandler.GithubCallback)
|
||||||
|
authGroup.POST("/guest", authHandler.GuestLogin)
|
||||||
authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me)
|
authGroup.GET("/me", authMiddleware.RequireAuth(), authHandler.Me)
|
||||||
authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout)
|
authGroup.POST("/logout", authMiddleware.RequireAuth(), authHandler.Logout)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Println("Google callback state:", c.Query("state"))
|
|
||||||
// Exchange code for token
|
// Exchange code for token
|
||||||
token, err := h.googleConfig.Exchange(c.Request.Context(), c.Query("code"))
|
token, err := h.googleConfig.Exchange(c.Request.Context(), c.Query("code"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,8 +83,7 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Println("Google user info response status:", resp.Status)
|
|
||||||
log.Println("Google user info response headers:", resp.Header)
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
data, _ := io.ReadAll(resp.Body)
|
||||||
@@ -96,11 +95,11 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &userInfo); err != nil {
|
if err := json.Unmarshal(data, &userInfo); err != nil {
|
||||||
log.Printf("Failed to parse Google response: %v | Data: %s", err, string(data))
|
// Failed to parse Google response
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid Google response"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid Google response"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Println("Google user info:", 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(),
|
||||||
@@ -116,12 +115,9 @@ func (h *AuthHandler) GoogleCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create session and JWT
|
// Create session and JWT
|
||||||
jwt, err := h.createSessionAndJWT(c, user)
|
jwt, err := h.createSessionAndJWT(c, user, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("❌ DATABASE ERROR: %v\n", err)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": fmt.Sprintf("CreateSession Error: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +140,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid oauth state"})
|
||||||
return
|
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"})
|
||||||
@@ -178,7 +174,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
|
|||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &userInfo); err != nil {
|
if err := json.Unmarshal(data, &userInfo); err != nil {
|
||||||
log.Printf("Failed to parse GitHub response: %v | Data: %s", err, string(data))
|
// Failed to parse GitHub response
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid GitHub response"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid GitHub response"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,8 +203,7 @@ 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(),
|
||||||
@@ -224,7 +219,7 @@ func (h *AuthHandler) GithubCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create session and JWT
|
// Create session and JWT
|
||||||
jwt, err := h.createSessionAndJWT(c, user)
|
jwt, err := h.createSessionAndJWT(c, user, 7*24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||||
return
|
return
|
||||||
@@ -273,12 +268,47 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GuestLogin creates a temporary guest user and returns a JWT
|
||||||
|
func (h *AuthHandler) GuestLogin(c *gin.Context) {
|
||||||
|
// Generate random 4-byte hex string for guest ID
|
||||||
|
b := make([]byte, 4)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate guest ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guestHex := fmt.Sprintf("%x", b)
|
||||||
|
guestName := fmt.Sprintf("Guest-%s", guestHex)
|
||||||
|
guestEmail := fmt.Sprintf("guest-%s@guest.local", guestHex)
|
||||||
|
providerUserID := uuid.New().String()
|
||||||
|
|
||||||
|
user, err := h.store.UpsertUser(
|
||||||
|
c.Request.Context(),
|
||||||
|
"guest",
|
||||||
|
providerUserID,
|
||||||
|
guestEmail,
|
||||||
|
guestName,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create guest user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := h.createSessionAndJWT(c, user, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": jwt})
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: create session and JWT
|
// Helper: create session and JWT
|
||||||
func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User) (string, error) {
|
func (h *AuthHandler) createSessionAndJWT(c *gin.Context, user *models.User, expiry time.Duration) (string, error) {
|
||||||
expiresAt := time.Now().Add(7 * 24 * time.Hour) // 7 days
|
expiresAt := time.Now().Add(expiry)
|
||||||
|
|
||||||
// Generate JWT first (we need it for session) - now includes avatar URL
|
// Generate JWT first (we need it for session) - now includes avatar URL
|
||||||
jwt, err := auth.GenerateJWT(user.ID, user.Name, user.Email, user.AvatarURL, h.cfg.JWTSecret, 7*24*time.Hour)
|
jwt, err := auth.GenerateJWT(user.ID, user.Name, user.Email, user.AvatarURL, h.cfg.JWTSecret, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -306,7 +336,7 @@ func (h *AuthHandler) generateStateOauthCookie(w http.ResponseWriter) string {
|
|||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
n, err := rand.Read(b)
|
n, err := rand.Read(b)
|
||||||
if err != nil || n != 16 {
|
if err != nil || n != 16 {
|
||||||
fmt.Printf("Failed to generate random state: %v\n", err)
|
// Failed to generate random state
|
||||||
return "" // Critical for CSRF security
|
return "" // Critical for CSRF security
|
||||||
}
|
}
|
||||||
state := base64.URLEncoding.EncodeToString(b)
|
state := base64.URLEncoding.EncodeToString(b)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
// Validate share token
|
// Validate share token
|
||||||
valid, err := wsh.store.ValidateShareToken(c.Request.Context(), documentID, shareToken)
|
valid, err := wsh.store.ValidateShareToken(c.Request.Context(), documentID, shareToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error validating share token: %v", err)
|
// Error validating share token
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate share token"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate share token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
// Authenticated user - get their permission level
|
// Authenticated user - get their permission level
|
||||||
perm, err := wsh.store.GetUserPermission(c.Request.Context(), documentID, *userID)
|
perm, err := wsh.store.GetUserPermission(c.Request.Context(), documentID, *userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error getting user permission: %v", err)
|
// Error getting user permission
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
// Share token user - get share link permission
|
// Share token user - get share link permission
|
||||||
perm, err := wsh.store.GetShareLinkPermission(c.Request.Context(), documentID)
|
perm, err := wsh.store.GetShareLinkPermission(c.Request.Context(), documentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error getting share link permission: %v", err)
|
// Error getting share link permission
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check permissions"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
upgrader := wsh.getUpgrader()
|
upgrader := wsh.getUpgrader()
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to upgrade connection: %v", err)
|
// Failed to upgrade WebSocket connection
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context) {
|
|||||||
go client.ReadPump()
|
go client.ReadPump()
|
||||||
go wsh.replayBacklog(client, documentID)
|
go wsh.replayBacklog(client, documentID)
|
||||||
|
|
||||||
log.Printf("Client connected: %s (user: %s) to room: %s", clientID, userName, roomID)
|
// Client connected
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxReplayUpdates = 5000
|
const maxReplayUpdates = 5000
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ func runUpdatePersistWorker(ctx context.Context, msgBus messagebus.MessageBus, d
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case <-heartbeatTicker.C:
|
case <-heartbeatTicker.C:
|
||||||
logWorker(logger, "Update persist worker heartbeat", zap.String("server_id", serverID))
|
if logger != nil {
|
||||||
|
logger.Debug("Update persist worker heartbeat", zap.String("server_id", serverID))
|
||||||
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := processUpdatePersistence(ctx, msgBus, dbStore, logger, serverID); err != nil {
|
if err := processUpdatePersistence(ctx, msgBus, dbStore, logger, serverID); err != nil {
|
||||||
logWorker(logger, "Update persist worker tick failed", zap.Error(err))
|
logWorker(logger, "Update persist worker tick failed", zap.Error(err))
|
||||||
|
|||||||
3
backend/scripts/012_add_guest_provider.sql
Normal file
3
backend/scripts/012_add_guest_provider.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add 'guest' as a valid provider for guest mode login
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_provider_check;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_provider_check CHECK (provider IN ('google', 'github', 'guest'));
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import type { User } from '../types/auth';
|
import type { User } from '../types/auth';
|
||||||
import { API_BASE_URL, authFetch } from './client';
|
import { API_BASE_URL, authFetch } from './client';
|
||||||
|
|
||||||
|
export async function guestLogin(): Promise<string> {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/auth/guest`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to create guest session');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
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`);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to={`/login?redirect=${location.pathname}`} replace />;
|
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function ShareModal({
|
|||||||
const [permission, setPermission] = useState<'view' | 'edit'>('view');
|
const [permission, setPermission] = useState<'view' | 'edit'>('view');
|
||||||
|
|
||||||
// Form state for link sharing
|
// Form state for link sharing
|
||||||
const [linkPermission, setLinkPermission] = useState<'view' | 'edit'>('view');
|
const [linkPermission, setLinkPermission] = useState<'view' | 'edit'>('edit');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Load shares on mount
|
// Load shares on mount
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { User, AuthContextType } from '../types/auth';
|
import type { User, AuthContextType } from '../types/auth';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
@@ -40,7 +40,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
initAuth();
|
initAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (newToken: string) => {
|
const login = useCallback(async (newToken: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -60,7 +60,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
@@ -7,8 +7,12 @@ function AuthCallback() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const processedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (processedRef.current) return;
|
||||||
|
processedRef.current = true;
|
||||||
|
|
||||||
const handleCallback = async () => {
|
const handleCallback = async () => {
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
const redirect =
|
const redirect =
|
||||||
|
|||||||
@@ -141,6 +141,19 @@
|
|||||||
background: hsl(var(--surface));
|
background: hsl(var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.landing-login-button.guest {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed hsl(var(--border));
|
||||||
|
color: hsl(var(--text-secondary));
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-button.guest:hover {
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
.landing-login-button.large {
|
.landing-login-button.large {
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { guestLogin } from '../api/auth';
|
||||||
import FloatingGem from '../components/PixelSprites/FloatingGem';
|
import FloatingGem from '../components/PixelSprites/FloatingGem';
|
||||||
import PixelIcon from '../components/PixelIcon/PixelIcon';
|
import PixelIcon from '../components/PixelIcon/PixelIcon';
|
||||||
import DocNestLogo from '../assets/docnest/docnest-icon-128.png';
|
import DocNestLogo from '../assets/docnest/docnest-icon-128.png';
|
||||||
@@ -6,6 +10,10 @@ import { API_BASE_URL } from '../config';
|
|||||||
import './LandingPage.css';
|
import './LandingPage.css';
|
||||||
|
|
||||||
function LandingPage() {
|
function LandingPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [guestLoading, setGuestLoading] = useState(false);
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
window.location.href = `${API_BASE_URL}/auth/google`;
|
window.location.href = `${API_BASE_URL}/auth/google`;
|
||||||
};
|
};
|
||||||
@@ -14,6 +22,19 @@ function LandingPage() {
|
|||||||
window.location.href = `${API_BASE_URL}/auth/github`;
|
window.location.href = `${API_BASE_URL}/auth/github`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGuestLogin = async () => {
|
||||||
|
try {
|
||||||
|
setGuestLoading(true);
|
||||||
|
const token = await guestLogin();
|
||||||
|
await login(token);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Guest login failed:', err);
|
||||||
|
} finally {
|
||||||
|
setGuestLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="landing-page">
|
<div className="landing-page">
|
||||||
<div className="landing-theme-toggle">
|
<div className="landing-theme-toggle">
|
||||||
@@ -61,6 +82,13 @@ function LandingPage() {
|
|||||||
<span>Continue with GitHub</span>
|
<span>Continue with GitHub</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className="landing-login-button guest"
|
||||||
|
onClick={handleGuestLogin}
|
||||||
|
disabled={guestLoading}
|
||||||
|
>
|
||||||
|
{guestLoading ? 'Entering...' : 'Try as Guest'}
|
||||||
|
</button>
|
||||||
<p className="hero-note">No credit card required.</p>
|
<p className="hero-note">No credit card required.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -105,3 +105,36 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider::before,
|
||||||
|
.login-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--text-secondary));
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed hsl(var(--border));
|
||||||
|
color: hsl(var(--text-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-button:hover {
|
||||||
|
background: hsl(var(--surface-hover, var(--border) / 0.1));
|
||||||
|
color: hsl(var(--text-primary));
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { guestLogin } from '../api/auth';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
import DocNestLogo from '../assets/docnest/docnest-icon-128.png';
|
import DocNestLogo from '../assets/docnest/docnest-icon-128.png';
|
||||||
import ThemeToggle from '../components/ThemeToggle';
|
import ThemeToggle from '../components/ThemeToggle';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading, login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const [guestLoading, setGuestLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && user) {
|
if (!loading && user) {
|
||||||
@@ -20,7 +22,7 @@ function LoginPage() {
|
|||||||
const saveRedirectAndGo = (oauthUrl: string) => {
|
const saveRedirectAndGo = (oauthUrl: string) => {
|
||||||
const redirect = searchParams.get('redirect');
|
const redirect = searchParams.get('redirect');
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
sessionStorage.setItem('oauth_redirect', redirect);
|
sessionStorage.setItem('oauth_redirect', decodeURIComponent(redirect));
|
||||||
}
|
}
|
||||||
window.location.href = oauthUrl;
|
window.location.href = oauthUrl;
|
||||||
};
|
};
|
||||||
@@ -33,6 +35,20 @@ function LoginPage() {
|
|||||||
saveRedirectAndGo(`${API_BASE_URL}/auth/github`);
|
saveRedirectAndGo(`${API_BASE_URL}/auth/github`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGuestLogin = async () => {
|
||||||
|
try {
|
||||||
|
setGuestLoading(true);
|
||||||
|
const token = await guestLogin();
|
||||||
|
await login(token);
|
||||||
|
const redirect = searchParams.get('redirect');
|
||||||
|
navigate(redirect ? decodeURIComponent(redirect) : '/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Guest login failed:', err);
|
||||||
|
} finally {
|
||||||
|
setGuestLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
@@ -93,6 +109,18 @@ function LoginPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Continue with GitHub
|
Continue with GitHub
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="login-divider">
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="login-button guest-button"
|
||||||
|
onClick={handleGuestLogin}
|
||||||
|
disabled={guestLoading}
|
||||||
|
>
|
||||||
|
{guestLoading ? 'Entering...' : 'Continue as Guest'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user