diff --git a/.gitignore b/.gitignore index a96ebf7..007c8cd 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,5 @@ android/local.properties docker-data/ .obsidian .excalidraw -.gocache \ No newline at end of file +.gocache +apps/web/.jest-cache/ diff --git a/apps/server/cmd/cors_test.go b/apps/server/cmd/cors_test.go new file mode 100644 index 0000000..33098f4 --- /dev/null +++ b/apps/server/cmd/cors_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" +) + +func TestCORSMiddlewareAllowsWebClientHeaders(t *testing.T) { + e := echo.New() + e.Use(corsMiddleware("http://localhost:3000")) + e.POST("/api/v1/auth/login", func(c *echo.Context) error { + return c.NoContent(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodOptions, "/api/v1/auth/login", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + req.Header.Set("Access-Control-Request-Headers", "content-type,x-requested-with") + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusNoContent, rec.Code) + require.Equal(t, "http://localhost:3000", rec.Header().Get("Access-Control-Allow-Origin")) + require.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials")) + + allowedHeaders := strings.ToLower(rec.Header().Get("Access-Control-Allow-Headers")) + require.Contains(t, allowedHeaders, "content-type") + require.Contains(t, allowedHeaders, "x-requested-with") +} diff --git a/apps/server/cmd/main.go b/apps/server/cmd/main.go index cf73277..7e2d697 100644 --- a/apps/server/cmd/main.go +++ b/apps/server/cmd/main.go @@ -5,7 +5,7 @@ import ( "time" "github.com/coderz-space/coderz.space/internal/common/logger" - "github.com/coderz-space/coderz.space/internal/common/middleware" + appMiddleware "github.com/coderz-space/coderz.space/internal/common/middleware" "github.com/coderz-space/coderz.space/internal/common/middleware/timeout" config "github.com/coderz-space/coderz.space/internal/config" "github.com/coderz-space/coderz.space/internal/container" @@ -47,8 +47,33 @@ import ( // @tag.name Bootcamp Enrollments // @tag.description Bootcamp enrollment management endpoints -func main() { +func corsConfig(frontendOrigin string) echoMiddleware.CORSConfig { + return echoMiddleware.CORSConfig{ + AllowOrigins: []string{frontendOrigin}, + AllowMethods: []string{ + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + }, + AllowHeaders: []string{ + "Origin", + "Content-Type", + "Accept", + "Authorization", + "X-Requested-With", + }, + ExposeHeaders: []string{"Content-Length", "Content-Type", "X-Request-Id"}, + AllowCredentials: true, + } +} +func corsMiddleware(frontendOrigin string) echo.MiddlewareFunc { + return echoMiddleware.CORSWithConfig(corsConfig(frontendOrigin)) +} + +func main() { cfg := config.LoadConfig() logger.Initialize(cfg) defer func() { @@ -64,15 +89,9 @@ func main() { e := echo.New() // middleware - e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ - AllowOrigins: []string{cfg.FrontendOrigin}, - AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, - ExposeHeaders: []string{"Content-Length", "Content-Type", "X-Request-Id"}, - AllowCredentials: true, - })) - e.Use(middleware.ZapLogger()) - e.Use(middleware.Recovery()) + e.Use(corsMiddleware(cfg.FrontendOrigin)) + e.Use(appMiddleware.ZapLogger()) + e.Use(appMiddleware.Recovery()) e.Use(timeout.TimeoutMiddleware(30 * time.Second)) // 30 second timeout to prevent resource exhaustion // swagger docs diff --git a/apps/server/internal/modules/auth/service.go b/apps/server/internal/modules/auth/service.go index fc986f5..8cce451 100644 --- a/apps/server/internal/modules/auth/service.go +++ b/apps/server/internal/modules/auth/service.go @@ -6,21 +6,25 @@ import ( "encoding/hex" "errors" "fmt" + "strings" "time" + "github.com/coderz-space/coderz.space/internal/common/email" "github.com/coderz-space/coderz.space/internal/common/logger" "github.com/coderz-space/coderz.space/internal/common/utils" - "github.com/coderz-space/coderz.space/internal/common/email" "github.com/coderz-space/coderz.space/internal/config" db "github.com/coderz-space/coderz.space/internal/db/sqlc" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" ) +const errEmailAlreadyExists = "EMAIL_ALREADY_EXISTS" + type Service struct { - queries *db.Queries - config *config.Config + queries *db.Queries + config *config.Config emailService email.Service } @@ -46,12 +50,27 @@ func (s *Service) Signup(ctx context.Context, req SignupRequest) (*AuthResponseD Role: db.UserRoleUser, }) if err != nil { - return nil, err + return nil, normalizeSignupError(err) } return s.generateAuthData(ctx, &user) } +func normalizeSignupError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "users_email_key" { + return errors.New(errEmailAlreadyExists) + } + + errMsg := err.Error() + if strings.Contains(errMsg, "users_email_key") || + strings.Contains(errMsg, "duplicate key value violates unique constraint") { + return errors.New(errEmailAlreadyExists) + } + + return err +} + func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponseData, error) { user, err := s.queries.GetUserByEmail(ctx, pgtype.Text{String: req.Email, Valid: true}) if err != nil { diff --git a/apps/server/internal/modules/auth/signup_handler_test.go b/apps/server/internal/modules/auth/signup_handler_test.go index 23d73cc..3bb3b9f 100644 --- a/apps/server/internal/modules/auth/signup_handler_test.go +++ b/apps/server/internal/modules/auth/signup_handler_test.go @@ -108,8 +108,8 @@ func TestHandler_Signup(t *testing.T) { checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { var resp map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &resp) - if resp["message"] != "duplicate key value violates unique constraint" { - t.Errorf("Expected database error message, got %v", resp["message"]) + if resp["message"] != "EMAIL_ALREADY_EXISTS" { + t.Errorf("Expected EMAIL_ALREADY_EXISTS, got %v", resp["message"]) } }, }, diff --git a/apps/web/.gitignore b/apps/web/.gitignore index a7b2064..6b87d6d 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/.jest-cache/ # next.js /.next/ @@ -40,4 +41,4 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -.kiro \ No newline at end of file +.kiro diff --git a/apps/web/__tests__/example.test.tsx b/apps/web/__tests__/example.test.tsx index 730f292..7f5d0f3 100644 --- a/apps/web/__tests__/example.test.tsx +++ b/apps/web/__tests__/example.test.tsx @@ -1,6 +1,3 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; - describe('Example test', () => { it('should pass', () => { expect(1 + 1).toBe(2); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index e4b8221..ea57fea 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import Script from "next/script"; import "./globals.css"; import ThemeToggle from "@/components/ThemeToggle"; @@ -15,13 +16,10 @@ export default function RootLayout({ }>) { return ( - - {/* Blocking script: sets dark class before first paint to avoid flash */} - - + {children} diff --git a/apps/web/components/HeroSection.tsx b/apps/web/components/HeroSection.tsx index 2b53727..83ff067 100644 --- a/apps/web/components/HeroSection.tsx +++ b/apps/web/components/HeroSection.tsx @@ -2,19 +2,41 @@ import { HeroSectionProps } from "@/types"; export default function HeroSection({ onGetStarted }: HeroSectionProps) { return ( -
-

- Welcome to Algo Buddy -

-

- A collaborative platform where developers master DSA and tech stacks through peer learning, problem-solving, and real progress tracking. -

- +
+
+
+
+

+ Peer learning for serious DSA practice +

+

+ Welcome to Algo Buddy +

+

+ A collaborative platform where developers master DSA and tech stacks through peer learning, problem-solving, and real progress tracking. +

+ +
+ +
+ {[ + ["01", "Choose your role", "Mentor and mentee paths stay focused from the first click."], + ["02", "Practice with structure", "Assignments and sheets keep daily progress visible."], + ["03", "Track real growth", "Profiles and leaderboards make outcomes easy to scan."], + ].map(([step, title, text]) => ( +
+

{step}

+

{title}

+

{text}

+
+ ))} +
+
); } diff --git a/apps/web/components/LandingPage.tsx b/apps/web/components/LandingPage.tsx index 26d3321..7fa787e 100644 --- a/apps/web/components/LandingPage.tsx +++ b/apps/web/components/LandingPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import type { Role } from "@/types"; import HeroSection from "@/components/HeroSection"; import RoleCard from "@/components/RoleCard"; @@ -14,33 +14,38 @@ export default function LandingPage() { const [view, setView] = useState("none"); const [activeRole, setActiveRole] = useState(null); - const handleSelectRole = async (role: Role) => { + const closeDialog = useCallback(() => setView("none"), []); + const showRoleSelect = useCallback(() => setView("roleSelect"), []); + const showSignUp = useCallback(() => setView("signUp"), []); + const showLogin = useCallback(() => setView("login"), []); + + const handleSelectRole = useCallback(async (role: Role) => { await selectRole(role); setActiveRole(role); setView("login"); - }; + }, []); return ( -
- setView("roleSelect")} /> +
+ {view === "roleSelect" && ( - setView("none")} /> + )} {view === "login" && activeRole && ( setView("none")} - onSignUp={() => setView("signUp")} + onClose={closeDialog} + onSignUp={showSignUp} /> )} {view === "signUp" && activeRole && ( setView("none")} - onBackToLogin={() => setView("login")} + onClose={closeDialog} + onBackToLogin={showLogin} /> )}
diff --git a/apps/web/components/MenteeLoginCard.tsx b/apps/web/components/MenteeLoginCard.tsx index 0d8fcbe..d507300 100644 --- a/apps/web/components/MenteeLoginCard.tsx +++ b/apps/web/components/MenteeLoginCard.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState } from "react"; +import { useState, type FormEvent } from "react"; import { useRouter } from "next/navigation"; import { loginMenteeByEmail } from "@/services/auth"; +import Modal from "@/components/Modal"; interface MenteeLoginCardProps { role: "mentor" | "mentee"; @@ -25,7 +26,7 @@ function EyeIcon({ visible }: { visible: boolean }) { } const inputClass = - "w-full rounded-lg border border-purple-300 bg-white px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-700 dark:bg-gray-800 dark:text-gray-100"; + "w-full rounded-lg border border-purple-200 bg-white px-4 py-2 text-gray-900 transition focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-800 dark:bg-gray-800 dark:text-gray-100"; export default function MenteeLoginCard({ role, onClose, onSignUp }: MenteeLoginCardProps) { const [email, setEmail] = useState(""); @@ -35,7 +36,8 @@ export default function MenteeLoginCard({ role, onClose, onSignUp }: MenteeLogin const [loading, setLoading] = useState(false); const router = useRouter(); - const handleLogin = async () => { + const handleLogin = async (event?: FormEvent) => { + event?.preventDefault(); setError(""); if (!email.trim() || !password) { setError("Please fill in all fields."); @@ -69,10 +71,10 @@ export default function MenteeLoginCard({ role, onClose, onSignUp }: MenteeLogin }; return ( -
-
event.stopPropagation()} + +

{role === "mentor" ? "Mentor Login" : "Mentee Login"} @@ -84,6 +86,7 @@ export default function MenteeLoginCard({ role, onClose, onSignUp }: MenteeLogin value={email} onChange={(event) => setEmail(event.target.value)} className={inputClass} + autoComplete="email" />
@@ -93,6 +96,7 @@ export default function MenteeLoginCard({ role, onClose, onSignUp }: MenteeLogin value={password} onChange={(event) => setPassword(event.target.value)} className={`${inputClass} pr-10`} + autoComplete="current-password" />
- {error ?

{error}

: null} - -

+ {role === "mentee" ? ( ) : null} -
+ ); } diff --git a/apps/web/components/MenteeSignUpCard.tsx b/apps/web/components/MenteeSignUpCard.tsx index cf1d370..8c873f7 100644 --- a/apps/web/components/MenteeSignUpCard.tsx +++ b/apps/web/components/MenteeSignUpCard.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, type FormEvent } from "react"; import { registerMentee } from "@/services/auth"; +import Modal from "@/components/Modal"; interface MenteeSignUpCardProps { role: "mentor" | "mentee"; @@ -24,7 +25,7 @@ function EyeIcon({ visible }: { visible: boolean }) { } const inputClass = - "w-full rounded-lg border border-purple-300 bg-white px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-700 dark:bg-gray-800 dark:text-gray-100"; + "w-full rounded-lg border border-purple-200 bg-white px-4 py-2 text-gray-900 transition focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-800 dark:bg-gray-800 dark:text-gray-100"; export default function MenteeSignUpCard({ role, onClose, onBackToLogin }: MenteeSignUpCardProps) { const [firstName, setFirstName] = useState(""); @@ -39,7 +40,8 @@ export default function MenteeSignUpCard({ role, onClose, onBackToLogin }: Mente const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const handleSignUp = async () => { + const handleSignUp = async (event?: FormEvent) => { + event?.preventDefault(); setError(""); if (role !== "mentee") { setError("Mentor accounts are provisioned separately."); @@ -77,27 +79,29 @@ export default function MenteeSignUpCard({ role, onClose, onBackToLogin }: Mente }; return ( -
-
event.stopPropagation()} + +

Mentee Sign Up

-
+
setFirstName(event.target.value)} - className="w-1/2 rounded-lg border border-purple-300 bg-white px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-700 dark:bg-gray-800 dark:text-gray-100" + className={inputClass} + autoComplete="given-name" /> setLastName(event.target.value)} - className="w-1/2 rounded-lg border border-purple-300 bg-white px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-700 dark:bg-gray-800 dark:text-gray-100" + className={inputClass} + autoComplete="family-name" />
@@ -107,6 +111,7 @@ export default function MenteeSignUpCard({ role, onClose, onBackToLogin }: Mente value={username} onChange={(event) => setUsername(event.target.value)} className={inputClass} + autoComplete="username" /> setEmail(event.target.value)} className={inputClass} + autoComplete="email" />
@@ -124,6 +130,7 @@ export default function MenteeSignUpCard({ role, onClose, onBackToLogin }: Mente value={password} onChange={(event) => setPassword(event.target.value)} className={`${inputClass} pr-10`} + autoComplete="new-password" /> - -
-
+
+
); } diff --git a/apps/web/components/Modal.tsx b/apps/web/components/Modal.tsx index 35e3f34..7f8e8a7 100644 --- a/apps/web/components/Modal.tsx +++ b/apps/web/components/Modal.tsx @@ -21,7 +21,9 @@ export default function Modal({ onClose, children, className = "" }: ModalProps) return (
{ if (innerRef.current && !innerRef.current.contains(e.target as Node)) { onClose(); diff --git a/apps/web/components/RoleCard.tsx b/apps/web/components/RoleCard.tsx index 25bacfb..85d864c 100644 --- a/apps/web/components/RoleCard.tsx +++ b/apps/web/components/RoleCard.tsx @@ -1,31 +1,24 @@ import { RoleCardProps } from "@/types"; +import Modal from "@/components/Modal"; export default function RoleCard({ onSelectRole, onClose }: RoleCardProps) { return ( -
-
e.stopPropagation()} + +

+ Log in as +

+ - -
-
+ Mentor + + + ); } diff --git a/apps/web/components/ThemeToggle.tsx b/apps/web/components/ThemeToggle.tsx index ee977cb..edbc0fc 100644 --- a/apps/web/components/ThemeToggle.tsx +++ b/apps/web/components/ThemeToggle.tsx @@ -29,9 +29,10 @@ export default function ThemeToggle() { return (