From 71c27b28a8b4e210e138cdfeb30e5524bd1a7b92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 19:04:55 +0000 Subject: [PATCH 1/3] fix(frontend): wire landing links to index, add privacy menu item, add google sign-in flow Agent-Logs-Url: https://github.com/devXyi/prexus-intelligence/sessions/3b81347b-26ec-43c0-9ecb-c118a784ef0f Co-authored-by: devXyi <265634822+devXyi@users.noreply.github.com> --- backend/backend/apps/api-gateway/auth.go | 115 +++++++++++++++++++++ backend/backend/apps/api-gateway/main.go | 1 + frontend/index.html | 122 ++++++++++++++++++++++- frontend/landing.html | 9 +- 4 files changed, 242 insertions(+), 5 deletions(-) diff --git a/backend/backend/apps/api-gateway/auth.go b/backend/backend/apps/api-gateway/auth.go index 8b75ee6..0e9b593 100644 --- a/backend/backend/apps/api-gateway/auth.go +++ b/backend/backend/apps/api-gateway/auth.go @@ -5,8 +5,12 @@ package main import ( "context" + "database/sql" + "encoding/json" + "errors" "log" "net/http" + "net/url" "os" "strings" "time" @@ -34,6 +38,10 @@ type LoginRequest struct { Password string `json:"password" binding:"required"` } +type GoogleLoginRequest struct { + Credential string `json:"credential" binding:"required"` +} + type AuthResponse struct { Token string `json:"token"` User UserDTO `json:"user"` @@ -54,6 +62,14 @@ type Claims struct { jwt.RegisteredClaims } +type GoogleTokenInfo struct { + Audience string `json:"aud"` + Email string `json:"email"` + EmailVerified string `json:"email_verified"` + Name string `json:"name"` + Subject string `json:"sub"` +} + // ───────────────────────────────────────────────────────────── // Register // ───────────────────────────────────────────────────────────── @@ -160,6 +176,105 @@ func handleLogin(c *gin.Context) { }) } +func verifyGoogleIDToken(ctx context.Context, idToken string) (*GoogleTokenInfo, error) { + tokenInfoURL := "https://oauth2.googleapis.com/tokeninfo?id_token=" + url.QueryEscape(idToken) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenInfoURL, nil) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, errors.New("invalid Google credential") + } + + var info GoogleTokenInfo + if err := json.NewDecoder(res.Body).Decode(&info); err != nil { + return nil, err + } + + if info.Email == "" || info.Subject == "" || info.EmailVerified != "true" { + return nil, errors.New("Google account is not eligible for sign-in") + } + + if expectedAud := strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_ID")); expectedAud != "" && info.Audience != expectedAud { + return nil, errors.New("Google credential audience mismatch") + } + + return &info, nil +} + +func handleGoogleLogin(c *gin.Context) { + var req GoogleLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), authTimeout) + defer cancel() + + tokenInfo, err := verifyGoogleIDToken(ctx, req.Credential) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + email := normalizeEmail(tokenInfo.Email) + + var id int64 + var fullName, orgName, role string + err = DB.QueryRowContext(ctx, + `SELECT id,COALESCE(full_name,''),COALESCE(org_name,''),role + FROM users WHERE email=$1`, + email, + ).Scan(&id, &fullName, &orgName, &role) + + if errors.Is(err, sql.ErrNoRows) { + role = "user" + fullName = strings.TrimSpace(tokenInfo.Name) + seed := "google-oauth-" + tokenInfo.Subject + "-" + time.Now().UTC().Format(time.RFC3339Nano) + hash, hashErr := bcrypt.GenerateFromPassword([]byte(seed), bcrypt.DefaultCost) + if hashErr != nil { + log.Printf("google signup hash error: %v", hashErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create account"}) + return + } + + insertErr := DB.QueryRowContext(ctx, + `INSERT INTO users (email,password_hash,full_name,org_name,role,created_at) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, + email, string(hash), fullName, "", role, time.Now().UTC(), + ).Scan(&id) + if insertErr != nil { + log.Printf("google signup DB error: %v", insertErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create account"}) + return + } + } else if err != nil { + log.Printf("google login DB error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"}) + return + } + + token, err := issueToken(id, email, role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Token generation failed"}) + return + } + + c.JSON(http.StatusOK, AuthResponse{ + Token: token, + User: UserDTO{ID: id, Email: email, FullName: fullName, OrgName: orgName, Role: role}, + }) +} + // ───────────────────────────────────────────────────────────── // Me // ───────────────────────────────────────────────────────────── diff --git a/backend/backend/apps/api-gateway/main.go b/backend/backend/apps/api-gateway/main.go index d62828e..636cade 100644 --- a/backend/backend/apps/api-gateway/main.go +++ b/backend/backend/apps/api-gateway/main.go @@ -167,6 +167,7 @@ func main() { r.GET("/health", RateLimitMiddleware(), handleHealth) r.POST("/register", RateLimitMiddleware(), handleRegister) r.POST("/login", RateLimitMiddleware(), handleLogin) + r.POST("/login/google", RateLimitMiddleware(), handleGoogleLogin) // ── Protected Routes (Auth + RBAC) ──────────────────── auth := r.Group("/", AuthMiddleware()) diff --git a/frontend/index.html b/frontend/index.html index 3041593..c6192f9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -74,6 +74,12 @@ .btn-auth{width:100%;padding:14px;background:var(--cobalt);color:white;border:none;border-radius:8px;cursor:pointer;font-family:var(--font-data);font-size:11px;font-weight:600;letter-spacing:.14em;text-transform:uppercase;transition:var(--transition);box-shadow:0 0 20px rgba(14,165,233,.3)} .btn-auth:hover:not(:disabled){filter:brightness(1.12);box-shadow:0 0 32px rgba(14,165,233,.5)} .btn-auth:disabled{opacity:.5;cursor:not-allowed} +.auth-divider{display:flex;align-items:center;gap:10px;margin:4px 0} +.auth-divider::before,.auth-divider::after{content:'';flex:1;height:1px;background:rgba(255,255,255,.08)} +.auth-divider span{font-family:var(--font-data);font-size:9px;letter-spacing:.12em;color:var(--ghost);text-transform:uppercase} +.btn-google{width:100%;padding:12px 14px;background:rgba(255,255,255,.02);color:var(--ice);border:1px solid rgba(255,255,255,.14);border-radius:8px;cursor:pointer;font-family:var(--font-data);font-size:10px;letter-spacing:.1em;text-transform:uppercase;transition:var(--transition);display:flex;align-items:center;justify-content:center;gap:8px} +.btn-google:hover:not(:disabled){border-color:rgba(14,165,233,.4);background:rgba(14,165,233,.08)} +.btn-google:disabled{opacity:.5;cursor:not-allowed} .btn-ghost{background:transparent;border:1px solid var(--border);color:var(--steel);padding:8px 18px;border-radius:10px;cursor:pointer;font-family:var(--font-data);font-size:10px;letter-spacing:.12em;text-transform:uppercase;transition:var(--transition)} .btn-ghost:hover{border-color:rgba(14,165,233,.3);color:var(--ice)} @@ -263,6 +269,11 @@
+