diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index a205e2b0..ffd2e7b7 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -141,17 +141,21 @@ jobs: - name: Create auth directory run: mkdir -p e2e/.auth + # List Playwright projects (for visibility) + - name: List Playwright projects + run: npx playwright test --list --reporter=list + # Run Playwright tests - name: Run Playwright tests run: npx playwright test --reporter=list env: CI: true - # Upload test results on failure + # Upload Playwright HTML report - uses: actions/upload-artifact@v4 - if: failure() + if: always() with: - name: playwright-report + name: playwright-html-report path: playwright-report/ retention-days: 30 diff --git a/api/auth/user_management.py b/api/auth/user_management.py index c7a82196..c8b1254f 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -40,7 +40,7 @@ async def _get_user_info(api_token: str) -> Optional[Dict[str, Any]]: """ query = """ MATCH (i:Identity)-[:HAS_TOKEN]->(t:Token {id: $api_token}) - RETURN i.email, i.name, i.picture, (t IS NOT NULL AND timestamp() <= t.expires_at) AS token_valid + RETURN i.provider_user_id, i.email, i.name, i.picture, i.provider, (t IS NOT NULL AND timestamp() <= t.expires_at) AS token_valid """ try: @@ -56,13 +56,15 @@ async def _get_user_info(api_token: str) -> Optional[Dict[str, Any]]: if result.result_set: single_result = result.result_set[0] - token_valid = single_result[3] + token_valid = single_result[5] # Updated index due to new fields if token_valid: return { - "email": single_result[0], - "name": single_result[1], - "picture": single_result[2], + "id": single_result[0], # provider_user_id as id + "email": single_result[1], + "name": single_result[2], + "picture": single_result[3], + "provider": single_result[4], } # Delete invalid/expired token from DB for cleanup await delete_user_token(api_token) diff --git a/api/routes/auth.py b/api/routes/auth.py index 01613eb7..714499b4 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -114,10 +114,18 @@ def _verify_password(password: str, stored_password_hex: str) -> bool: return False def _sanitize_for_log(value: str) -> str: - """Sanitize user input for logging by removing newlines and carriage returns.""" + """Sanitize user input for logging by removing all newlines and separator characters.""" if not isinstance(value, str): - return str(value) - return value.replace('\r\n', '').replace('\n', '').replace('\r', '') + value = str(value) + # Remove CR, LF, CRLF, and Unicode line/paragraph separators + return ( + value + .replace('\r\n', '') + .replace('\n', '') + .replace('\r', '') + .replace('\u2028', '') + .replace('\u2029', '') + ) def _validate_email(email: str) -> bool: """Basic email validation.""" @@ -256,8 +264,25 @@ async def email_signup(request: Request, signup_data: EmailSignupRequest) -> JSO success, user_info = await ensure_user_in_organizations(email, email, f"{first_name} {last_name}", "email", api_token) - if success and user_info and user_info["new_identity"]: - logging.info("New user created: %s", _sanitize_for_log(email)) + # Check for system errors (success=False and user_info=None) + if not success and user_info is None: + logging.error("System error during user creation: [%s]", _sanitize_for_log(email)) + return JSONResponse( + {"success": False, "error": "Registration failed. Please try again later."}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Check if user already exists (success=False and user_info is not None) + if not success and user_info is not None: + logging.info("Signup attempt for existing user: [%s]", _sanitize_for_log(email)) + return JSONResponse( + {"success": False, "error": "Registration failed. Please try again."}, + status_code=status.HTTP_400_BAD_REQUEST + ) + + # New user created successfully (success=True and user_info["new_identity"]=True) + if success and user_info and user_info.get("new_identity"): + logging.info("New user created: [%s]", _sanitize_for_log(email)) # Hash password password_hash = _hash_password(password) @@ -265,10 +290,7 @@ async def email_signup(request: Request, signup_data: EmailSignupRequest) -> JSO # Set email hash await _set_mail_hash(email, password_hash) - else: - logging.info("User already exists: %s", _sanitize_for_log(email)) - - logging.info("User registration successful: %s", _sanitize_for_log(email)) + logging.info("User registration successful: [%s]", _sanitize_for_log(email)) response = JSONResponse({ "success": True, diff --git a/app/src/components/modals/LoginModal.tsx b/app/src/components/modals/LoginModal.tsx index 65c8a389..985ff6fa 100644 --- a/app/src/components/modals/LoginModal.tsx +++ b/app/src/components/modals/LoginModal.tsx @@ -1,22 +1,152 @@ +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { buildApiUrl, API_CONFIG } from "@/config/api"; +import { useAuth } from "@/contexts/AuthContext"; +import { Eye, EyeOff } from "lucide-react"; interface LoginModalProps { open: boolean; onOpenChange: (open: boolean) => void; - canClose?: boolean; // Whether user can close the modal (false for required login) + canClose?: boolean; + startInSignupMode?: boolean; } -const LoginModal = ({ open, onOpenChange, canClose = true }: LoginModalProps) => { - const handleGoogleLogin = () => { +const LoginModal = ({ open, onOpenChange, canClose = true, startInSignupMode = false }: LoginModalProps) => { + const { login, signup, refreshAuth, isAuthenticated } = useAuth(); + + const [mode, setMode] = useState<'signin' | 'signup'>(startInSignupMode ? 'signup' : 'signin'); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showEmailForm, setShowEmailForm] = useState(false); + const [justAuthenticated, setJustAuthenticated] = useState(false); + + useEffect(() => { + if (open) { + setMode(startInSignupMode ? 'signup' : 'signin'); + setShowEmailForm(false); + setError(''); + setFirstName(''); + setLastName(''); + setEmail(''); + setPassword(''); + setConfirmPassword(''); + setShowPassword(false); + setShowConfirmPassword(false); + setJustAuthenticated(false); + } + }, [open, startInSignupMode]); + + // Close modal automatically when authentication succeeds + useEffect(() => { + if (justAuthenticated && isAuthenticated && open) { + onOpenChange(false); + setJustAuthenticated(false); + } + }, [justAuthenticated, isAuthenticated, open, onOpenChange]); + + const handleGoogleAuth = () => { window.location.href = buildApiUrl(API_CONFIG.ENDPOINTS.LOGIN_GOOGLE); }; - const handleGithubLogin = () => { + const handleGithubAuth = () => { window.location.href = buildApiUrl(API_CONFIG.ENDPOINTS.LOGIN_GITHUB); }; + const handleEmailLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!email.trim() || !password) { + setError('Email and password are required'); + return; + } + setIsLoading(true); + try { + const result = await login.email(email, password); + if (result.success) { + // Refresh auth state and mark as authenticated + await refreshAuth(); + setJustAuthenticated(true); + // Clear form fields + setEmail(''); + setPassword(''); + setShowEmailForm(false); + setShowPassword(false); + } else { + setError(result.error || 'Login failed'); + } + } catch (err) { + setError('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleEmailSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!firstName.trim() || !lastName.trim() || !email.trim() || !password) { + setError('All fields are required'); + return; + } + if (password.length < 8) { + setError('Password must be at least 8 characters long'); + return; + } + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + setIsLoading(true); + try { + const result = await signup.email(firstName, lastName, email, password); + if (result.success) { + // Refresh auth state and mark as authenticated + await refreshAuth(); + setJustAuthenticated(true); + // Clear form fields + setFirstName(''); + setLastName(''); + setEmail(''); + setPassword(''); + setConfirmPassword(''); + setShowEmailForm(false); + setShowPassword(false); + setShowConfirmPassword(false); + } else { + setError(result.error || 'Signup failed'); + } + } catch (err) { + setError('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const switchMode = () => { + setMode(mode === 'signin' ? 'signup' : 'signin'); + setShowEmailForm(true); // Keep user in email form when switching + setError(''); + setEmail(''); + setPassword(''); + setConfirmPassword(''); + setFirstName(''); + setLastName(''); + setShowPassword(false); + setShowConfirmPassword(false); + }; + + const isSignIn = mode === 'signin'; + return ( > - Welcome to QueryWeaver + {isSignIn ? 'Welcome to QueryWeaver' : 'Create Your Account'} - Sign in to access your databases and start querying + {isSignIn ? 'Sign in to access your databases and start querying' : 'Sign up to start using QueryWeaver'} -
- - - -
- - {canClose && ( + {!showEmailForm ? ( +
+ + +
+
+ +
+
+ Or +
+
+ +
+ {isSignIn ? "Don't have an account? " : "Already have an account? "} + +
+
+ ) : isSignIn ? ( +
+
+ + setEmail(e.target.value)} required disabled={isLoading} data-testid="email-signin-input" /> +
+
+ +
+ setPassword(e.target.value)} required disabled={isLoading} className="pr-10" data-testid="password-signin-input" /> + +
+
+ {error &&
{error}
} +
+ + +
+
+ Don't have an account? +
+
+ ) : ( +
+
+
+ + setFirstName(e.target.value)} required disabled={isLoading} data-testid="firstname-input" /> +
+
+ + setLastName(e.target.value)} required disabled={isLoading} data-testid="lastname-input" /> +
+
+
+ + setEmail(e.target.value)} required disabled={isLoading} data-testid="email-signup-input" /> +
+
+ +
+ setPassword(e.target.value)} required disabled={isLoading} minLength={8} className="pr-10" data-testid="password-signup-input" /> + +
+

Must be at least 8 characters long

+
+
+ +
+ setConfirmPassword(e.target.value)} required disabled={isLoading} className="pr-10" data-testid="confirm-password-input" /> + +
+
+ {error &&
{error}
} +
+ + +
+
+ Already have an account? +
+
+ )} + {canClose && !showEmailForm && (
-

By signing in, you agree to our Terms of Service and Privacy Policy

+

By {isSignIn ? 'signing in' : 'signing up'}, you agree to our Terms of Service and Privacy Policy

)} diff --git a/app/src/config/api.ts b/app/src/config/api.ts index f4671e21..6bd31b15 100644 --- a/app/src/config/api.ts +++ b/app/src/config/api.ts @@ -18,6 +18,8 @@ export const API_CONFIG = { AUTH_STATUS: '/auth-status', LOGIN_GOOGLE: '/login/google', LOGIN_GITHUB: '/login/github', + LOGIN_EMAIL: '/login/email', + SIGNUP_EMAIL: '/signup/email', LOGOUT: '/logout', // Graph/Database management diff --git a/app/src/contexts/AuthContext.tsx b/app/src/contexts/AuthContext.tsx index 53e41d78..ae7da677 100644 --- a/app/src/contexts/AuthContext.tsx +++ b/app/src/contexts/AuthContext.tsx @@ -9,6 +9,10 @@ interface AuthContextType { login: { google: () => Promise; github: () => Promise; + email: (email: string, password: string) => Promise<{ success: boolean; error?: string }>; + }; + signup: { + email: (firstName: string, lastName: string, email: string, password: string) => Promise<{ success: boolean; error?: string }>; }; logout: () => Promise; refreshAuth: () => Promise; @@ -55,6 +59,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children login: { google: AuthService.loginWithGoogle, github: AuthService.loginWithGithub, + email: AuthService.loginWithEmail, + }, + signup: { + email: AuthService.signupWithEmail, }, logout: handleLogout, refreshAuth: checkAuth, diff --git a/app/src/pages/Index.tsx b/app/src/pages/Index.tsx index 824d321a..5a1e8c04 100644 --- a/app/src/pages/Index.tsx +++ b/app/src/pages/Index.tsx @@ -29,6 +29,8 @@ const Index = () => { const { toast } = useToast(); const [showDatabaseModal, setShowDatabaseModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); + const [loginModalCanClose, setLoginModalCanClose] = useState(true); + const [showSignupMode, setShowSignupMode] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [showSchemaViewer, setShowSchemaViewer] = useState(false); const [showTokensModal, setShowTokensModal] = useState(false); @@ -113,16 +115,14 @@ const Index = () => { // Show login modal when not authenticated after loading completes useEffect(() => { - // Only auto-open the login modal once per user/session to avoid locking - // the SPA when the backend is down or in demo mode. Allow users to - // dismiss it and remember that choice in sessionStorage. - if (!authLoading && !isAuthenticated) { - const dismissed = sessionStorage.getItem('loginModalDismissed'); - if (!dismissed) { - setShowLoginModal(true); - } + // Auto-open the login modal and keep it open until user authenticates + // Only disable closing if we're auto-opening (not if user manually opened it) + if (!authLoading && !isAuthenticated && !showLoginModal) { + setShowSignupMode(false); + setLoginModalCanClose(false); // Don't allow closing until authenticated + setShowLoginModal(true); } - }, [authLoading, isAuthenticated]); + }, [authLoading, isAuthenticated, showLoginModal]); const handleConnectDatabase = () => { if (isRefreshingSchema || isChatProcessing) return; @@ -418,7 +418,11 @@ const Index = () => { @@ -619,13 +627,18 @@ const Index = () => { { - setShowLoginModal(open); - if (!open) { - // Remember dismissal for this session to avoid pinning the modal - sessionStorage.setItem('loginModalDismissed', '1'); + // Allow closing if: + // 1. User is authenticated (successful login/signup), OR + // 2. Modal was manually opened (loginModalCanClose is true) + if (isAuthenticated || loginModalCanClose) { + setShowLoginModal(open); + if (!open) { + setShowSignupMode(false); + } } }} - canClose={true} + canClose={loginModalCanClose} + startInSignupMode={showSignupMode} /> { + try { + const response = await fetch(buildApiUrl(API_CONFIG.ENDPOINTS.LOGIN_EMAIL), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + return { success: false, error: data.error || 'Login failed' }; + } + + return { success: true }; + } catch (error) { + console.error('Failed to login with email:', error); + return { success: false, error: 'Failed to connect to authentication service' }; + } + } + + /** + * Sign up with email and password + */ + static async signupWithEmail( + firstName: string, + lastName: string, + email: string, + password: string + ): Promise<{ success: boolean; error?: string }> { + try { + const response = await fetch(buildApiUrl(API_CONFIG.ENDPOINTS.SIGNUP_EMAIL), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ firstName, lastName, email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + return { success: false, error: data.error || 'Signup failed' }; + } + + return { success: true }; + } catch (error) { + console.error('Failed to signup with email:', error); + return { success: false, error: 'Failed to connect to authentication service' }; + } + } + /** * Logout current user */ diff --git a/app/src/types/api.ts b/app/src/types/api.ts index 04e46ede..d901f2a8 100644 --- a/app/src/types/api.ts +++ b/app/src/types/api.ts @@ -6,7 +6,7 @@ export interface User { email: string; name?: string; picture?: string; - provider?: 'google' | 'github'; + provider?: 'google' | 'github' | 'email'; } // Authentication types diff --git a/e2e/logic/pom/emailAuthPage.ts b/e2e/logic/pom/emailAuthPage.ts new file mode 100644 index 00000000..8283e9ff --- /dev/null +++ b/e2e/logic/pom/emailAuthPage.ts @@ -0,0 +1,315 @@ +import { Locator, Page } from "@playwright/test"; +import { waitForElementToBeVisible } from "../../infra/utils"; +import BasePage from "../../infra/ui/basePage"; + +/** + * Email Authentication Page Object Model + * Handles all email signup and signin functionality + */ +export class EmailAuthPage extends BasePage { + // ==================== LOCATORS ==================== + + // Email Authentication Buttons + private get emailAuthBtn(): Locator { + return this.page.getByTestId("email-auth-btn"); + } + + // Email Signup Form Elements + private get emailSignupForm(): Locator { + return this.page.getByTestId("email-signup-form"); + } + + private get firstNameInput(): Locator { + return this.page.getByTestId("firstname-input"); + } + + private get lastNameInput(): Locator { + return this.page.getByTestId("lastname-input"); + } + + private get emailSignupInput(): Locator { + return this.page.getByTestId("email-signup-input"); + } + + private get passwordSignupInput(): Locator { + return this.page.getByTestId("password-signup-input"); + } + + private get confirmPasswordInput(): Locator { + return this.page.getByTestId("confirm-password-input"); + } + + private get createAccountBtn(): Locator { + return this.page.getByTestId("create-account-btn"); + } + + private get signupError(): Locator { + return this.page.getByTestId("signup-error"); + } + + // Email Signin Form Elements + private get emailSigninForm(): Locator { + return this.page.getByTestId("email-signin-form"); + } + + private get emailSigninInput(): Locator { + return this.page.getByTestId("email-signin-input"); + } + + private get passwordSigninInput(): Locator { + return this.page.getByTestId("password-signin-input"); + } + + private get signinSubmitBtn(): Locator { + return this.page.getByTestId("signin-submit-btn"); + } + + private get signinError(): Locator { + return this.page.getByTestId("signin-error"); + } + + // Form Toggle Links + private get switchToSignupLink(): Locator { + return this.page.getByTestId("switch-to-signup-link"); + } + + private get switchToSigninLink(): Locator { + return this.page.getByTestId("switch-to-signin-link"); + } + + private get switchModeLink(): Locator { + return this.page.getByTestId("switch-mode-link"); + } + + // User Display Elements + private get userEmailDisplay(): Locator { + return this.page.getByTestId("user-email-display"); + } + + // ==================== INTERACTION HELPERS ==================== + + // Email Authentication Button - InteractWhenVisible + private async interactWithEmailAuthBtn(): Promise { + const isVisible = await waitForElementToBeVisible(this.emailAuthBtn); + if (!isVisible) throw new Error("Email auth button is not visible!"); + return this.emailAuthBtn; + } + + // Email Signup Form Elements - InteractWhenVisible + private async interactWithEmailSignupForm(): Promise { + const isVisible = await waitForElementToBeVisible(this.emailSignupForm); + if (!isVisible) throw new Error("Email signup form is not visible!"); + return this.emailSignupForm; + } + + private async interactWithFirstNameInput(): Promise { + const isVisible = await waitForElementToBeVisible(this.firstNameInput); + if (!isVisible) throw new Error("First name input is not visible!"); + return this.firstNameInput; + } + + private async interactWithLastNameInput(): Promise { + const isVisible = await waitForElementToBeVisible(this.lastNameInput); + if (!isVisible) throw new Error("Last name input is not visible!"); + return this.lastNameInput; + } + + private async interactWithEmailSignupInput(): Promise { + const isVisible = await waitForElementToBeVisible(this.emailSignupInput); + if (!isVisible) throw new Error("Email signup input is not visible!"); + return this.emailSignupInput; + } + + private async interactWithPasswordSignupInput(): Promise { + const isVisible = await waitForElementToBeVisible(this.passwordSignupInput); + if (!isVisible) throw new Error("Password signup input is not visible!"); + return this.passwordSignupInput; + } + + private async interactWithConfirmPasswordInput(): Promise { + const isVisible = await waitForElementToBeVisible(this.confirmPasswordInput); + if (!isVisible) throw new Error("Confirm password input is not visible!"); + return this.confirmPasswordInput; + } + + private async interactWithCreateAccountBtn(): Promise { + const isVisible = await waitForElementToBeVisible(this.createAccountBtn); + if (!isVisible) throw new Error("Create account button is not visible!"); + return this.createAccountBtn; + } + + private async interactWithSignupError(): Promise { + const isVisible = await waitForElementToBeVisible(this.signupError); + if (!isVisible) throw new Error("Signup error is not visible!"); + return this.signupError; + } + + // Email Signin Form Elements - InteractWhenVisible + private async interactWithEmailSigninForm(): Promise { + const isVisible = await waitForElementToBeVisible(this.emailSigninForm); + if (!isVisible) throw new Error("Email signin form is not visible!"); + return this.emailSigninForm; + } + + private async interactWithEmailSigninInput(): Promise { + const isVisible = await waitForElementToBeVisible(this.emailSigninInput); + if (!isVisible) throw new Error("Email signin input is not visible!"); + return this.emailSigninInput; + } + + private async interactWithPasswordSigninInput(): Promise { + const isVisible = await waitForElementToBeVisible(this.passwordSigninInput); + if (!isVisible) throw new Error("Password signin input is not visible!"); + return this.passwordSigninInput; + } + + private async interactWithSigninSubmitBtn(): Promise { + const isVisible = await waitForElementToBeVisible(this.signinSubmitBtn); + if (!isVisible) throw new Error("Signin submit button is not visible!"); + return this.signinSubmitBtn; + } + + private async interactWithSigninError(): Promise { + const isVisible = await waitForElementToBeVisible(this.signinError); + if (!isVisible) throw new Error("Signin error is not visible!"); + return this.signinError; + } + + // Form Toggle Links - InteractWhenVisible + private async interactWithSwitchToSignupLink(): Promise { + const isVisible = await waitForElementToBeVisible(this.switchToSignupLink); + if (!isVisible) throw new Error("Switch to signup link is not visible!"); + return this.switchToSignupLink; + } + + private async interactWithSwitchToSigninLink(): Promise { + const isVisible = await waitForElementToBeVisible(this.switchToSigninLink); + if (!isVisible) throw new Error("Switch to signin link is not visible!"); + return this.switchToSigninLink; + } + + private async interactWithSwitchModeLink(): Promise { + const isVisible = await waitForElementToBeVisible(this.switchModeLink); + if (!isVisible) throw new Error("Switch mode link is not visible!"); + return this.switchModeLink; + } + + // ==================== PUBLIC METHODS ==================== + + // Email Authentication Button Actions + async clickEmailAuthBtn(): Promise { + const element = await this.interactWithEmailAuthBtn(); + await element.click(); + } + + // Email Signup Form Functions + async fillFirstName(firstName: string): Promise { + const element = await this.interactWithFirstNameInput(); + await element.fill(firstName); + } + + async fillLastName(lastName: string): Promise { + const element = await this.interactWithLastNameInput(); + await element.fill(lastName); + } + + async fillEmailField(email: string): Promise { + const element = await this.interactWithEmailSignupInput(); + await element.fill(email); + } + + async fillPasswordField(password: string): Promise { + const element = await this.interactWithPasswordSignupInput(); + await element.fill(password); + } + + async fillConfirmPasswordField(password: string): Promise { + const element = await this.interactWithConfirmPasswordInput(); + await element.fill(password); + } + + async clickCreateAccountBtn(): Promise { + const element = await this.interactWithCreateAccountBtn(); + await element.click(); + } + + async getSignupErrorText(): Promise { + const element = await this.interactWithSignupError(); + return await element.textContent() || ''; + } + + async waitForSignupErrorVisible(): Promise { + await this.interactWithSignupError(); + } + + // Email Signin Form Functions + async fillEmailFieldSignin(email: string): Promise { + const element = await this.interactWithEmailSigninInput(); + await element.fill(email); + } + + async fillPasswordFieldSignin(password: string): Promise { + const element = await this.interactWithPasswordSigninInput(); + await element.fill(password); + } + + async clickSignInBtn(): Promise { + const element = await this.interactWithSigninSubmitBtn(); + await element.click(); + } + + async getSigninErrorText(): Promise { + const element = await this.interactWithSigninError(); + return await element.textContent() || ''; + } + + async waitForSigninErrorVisible(): Promise { + await this.interactWithSigninError(); + } + + // Form State Functions + async waitForEmailSignupFormVisible(): Promise { + await this.interactWithEmailSignupForm(); + } + + async waitForEmailSigninFormVisible(): Promise { + await this.interactWithEmailSigninForm(); + } + + async isSignupFormVisible(): Promise { + try { + return await this.emailSignupForm.isVisible(); + } catch { + return false; + } + } + + async isSigninFormVisible(): Promise { + try { + return await this.emailSigninForm.isVisible(); + } catch { + return false; + } + } + + // Form Toggle Actions + async clickSwitchToSigninLink(): Promise { + const element = await this.interactWithSwitchToSigninLink(); + await element.click(); + } + + async clickSwitchToSignupLink(): Promise { + const element = await this.interactWithSwitchToSignupLink(); + await element.click(); + } + + async clickSwitchModeLink(): Promise { + const element = await this.interactWithSwitchModeLink(); + await element.click(); + } + + // User Information + async getUserEmail(): Promise { + return await this.userEmailDisplay.textContent() || ''; + } +} diff --git a/e2e/logic/pom/homePage.ts b/e2e/logic/pom/homePage.ts index e7359b25..18db4260 100644 --- a/e2e/logic/pom/homePage.ts +++ b/e2e/logic/pom/homePage.ts @@ -604,6 +604,23 @@ export class HomePage extends BasePage { await element.click(); } + // Form State Functions + async waitForLoginModalVisible(): Promise { + await this.interactWithLoginModal(); + } + + async waitForLoginModalHidden(timeout: number = 5000): Promise { + await this.loginModal.waitFor({ state: 'hidden', timeout }); + } + + async isLoginModalVisible(): Promise { + try { + return await this.loginModal.isVisible(); + } catch { + return false; + } + } + // Database Connection Modal Functions async selectDatabaseType(type: "postgresql" | "mysql"): Promise { const element = await this.interactWithDatabaseTypeSelect(); diff --git a/e2e/tests/emailAuth.spec.ts b/e2e/tests/emailAuth.spec.ts new file mode 100644 index 00000000..b07b1200 --- /dev/null +++ b/e2e/tests/emailAuth.spec.ts @@ -0,0 +1,412 @@ +import { test, expect } from '@playwright/test'; +import { getBaseUrl } from '../config/urls'; +import BrowserWrapper from '../infra/ui/browserWrapper'; +import ApiCalls from '../logic/api/apiCalls'; +import { HomePage } from '../logic/pom/homePage'; +import { EmailAuthPage } from '../logic/pom/emailAuthPage'; + +/** + * Email Authentication Tests + * Tests email signup and signin functionality + * Each test is atomic and independent + * + * IMPORTANT: These tests run WITHOUT pre-authentication + * They use the 'chromium-unauthenticated' and 'firefox-unauthenticated' projects + */ +test.describe('Email Authentication Tests', () => { + let browser: BrowserWrapper; + let homePage: HomePage; + let emailAuthPage: EmailAuthPage; + + // Generate unique email for each test to ensure atomicity + const generateTestEmail = (testName: string) => { + const timestamp = Date.now(); + const random = Math.floor(Math.random() * 10000); + return `test.${testName}.${timestamp}.${random}@example.com`; + }; + + test.beforeEach(async () => { + browser = new BrowserWrapper(); + homePage = await browser.createNewPage(HomePage, getBaseUrl()); + const page = await browser.getPage(); + emailAuthPage = new EmailAuthPage(page); + await browser.setPageToFullScreen(); + }); + + test.afterEach(async () => { + await browser.closeBrowser(); + }); + + test('should successfully sign up with valid email credentials', async () => { + // Generate unique credentials + const email = generateTestEmail('valid-signup'); + const firstName = 'John'; + const lastName = 'Doe'; + const password = 'SecurePass123!'; + + // When unauthenticated, the login modal appears automatically + // Wait for modal to be visible + await homePage.waitForLoginModalVisible(); + + // Modal opens in signin mode by default, switch to signup mode + // Note: switchMode() also shows the email form automatically + await emailAuthPage.clickSwitchModeLink(); + + // Wait for email signup form to appear + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Fill in signup form + await emailAuthPage.fillFirstName(firstName); + await emailAuthPage.fillLastName(lastName); + await emailAuthPage.fillEmailField(email); + await emailAuthPage.fillPasswordField(password); + await emailAuthPage.fillConfirmPasswordField(password); + + // Submit signup form + await emailAuthPage.clickCreateAccountBtn(); + + // Wait for successful signup (modal should close and user should be authenticated) + await homePage.waitForLoginModalHidden(10000); + + // Verify user is logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeTruthy(); + + // Verify user menu shows correct email + await homePage.clickOnUserMenu(); + const userEmail = await emailAuthPage.getUserEmail(); + expect(userEmail).toBe(email); + }); + + test('should fail signup without email', async () => { + const firstName = 'John'; + const lastName = 'Doe'; + const password = 'SecurePass123!'; + + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Switch to signup mode (this also shows the email form) + await emailAuthPage.clickSwitchModeLink(); + + // Wait for email signup form to appear + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Fill form WITHOUT email + await emailAuthPage.fillFirstName(firstName); + await emailAuthPage.fillLastName(lastName); + await emailAuthPage.fillPasswordField(password); + await emailAuthPage.fillConfirmPasswordField(password); + + // Attempt to submit (should be blocked by HTML5 validation) + await emailAuthPage.clickCreateAccountBtn(); + + // Verify modal is still open and error is shown or button is disabled + const isModalVisible = await homePage.isLoginModalVisible(); + expect(isModalVisible).toBeTruthy(); + + // User should still not be logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeFalsy(); + }); + + test('should fail signup without password', async () => { + const email = generateTestEmail('no-password'); + const firstName = 'John'; + const lastName = 'Doe'; + + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Switch to signup mode (this also shows the email form) + await emailAuthPage.clickSwitchModeLink(); + + // Wait for email signup form to appear + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Fill form WITHOUT password + await emailAuthPage.fillFirstName(firstName); + await emailAuthPage.fillLastName(lastName); + await emailAuthPage.fillEmailField(email); + + // Attempt to submit (should be blocked by HTML5 validation) + await emailAuthPage.clickCreateAccountBtn(); + + // Verify modal is still open + const isModalVisible = await homePage.isLoginModalVisible(); + expect(isModalVisible).toBeTruthy(); + + // User should still not be logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeFalsy(); + }); + + test('should fail signup without first name', async () => { + const email = generateTestEmail('no-firstname'); + const lastName = 'Doe'; + const password = 'SecurePass123!'; + + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Switch to signup mode (this also shows the email form) + await emailAuthPage.clickSwitchModeLink(); + + // Wait for email signup form to appear + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Fill form WITHOUT first name + await emailAuthPage.fillLastName(lastName); + await emailAuthPage.fillEmailField(email); + await emailAuthPage.fillPasswordField(password); + await emailAuthPage.fillConfirmPasswordField(password); + + // Attempt to submit (should be blocked by HTML5 validation) + await emailAuthPage.clickCreateAccountBtn(); + + // Verify modal is still open + const isModalVisible = await homePage.isLoginModalVisible(); + expect(isModalVisible).toBeTruthy(); + + // User should still not be logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeFalsy(); + }); + + test('should fail signup without last name', async () => { + const email = generateTestEmail('no-lastname'); + const firstName = 'John'; + const password = 'SecurePass123!'; + + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Switch to signup mode (this also shows the email form) + await emailAuthPage.clickSwitchModeLink(); + + // Wait for email signup form to appear + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Fill form WITHOUT last name + await emailAuthPage.fillFirstName(firstName); + await emailAuthPage.fillEmailField(email); + await emailAuthPage.fillPasswordField(password); + await emailAuthPage.fillConfirmPasswordField(password); + + // Attempt to submit (should be blocked by HTML5 validation) + await emailAuthPage.clickCreateAccountBtn(); + + // Verify modal is still open + const isModalVisible = await homePage.isLoginModalVisible(); + expect(isModalVisible).toBeTruthy(); + + // User should still not be logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeFalsy(); + }); + + test('should fail signup with password mismatch', async () => { + const email = generateTestEmail('password-mismatch'); + const firstName = 'John'; + const lastName = 'Doe'; + const password = 'SecurePass123!'; + const wrongPassword = 'DifferentPass456!'; + + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Switch to signup mode (this also shows the email form) + await emailAuthPage.clickSwitchModeLink(); + + // Wait for email signup form to appear + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Fill form with mismatched passwords + await emailAuthPage.fillFirstName(firstName); + await emailAuthPage.fillLastName(lastName); + await emailAuthPage.fillEmailField(email); + await emailAuthPage.fillPasswordField(password); + await emailAuthPage.fillConfirmPasswordField(wrongPassword); + + // Attempt to submit + await emailAuthPage.clickCreateAccountBtn(); + + // Verify error message is shown + await emailAuthPage.waitForSignupErrorVisible(); + const errorText = await emailAuthPage.getSignupErrorText(); + expect(errorText).toContain('Passwords do not match'); + + // User should still not be logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeFalsy(); + }); + + test('should fail signup with short password (less than 8 characters)', async () => { + const email = generateTestEmail('short-password'); + const firstName = 'John'; + const lastName = 'Doe'; + const shortPassword = 'Pass12!'; + + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Switch to signup mode (this also shows the email form) + await emailAuthPage.clickSwitchModeLink(); + + // Wait for email signup form to appear + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Fill form with short password + await emailAuthPage.fillFirstName(firstName); + await emailAuthPage.fillLastName(lastName); + await emailAuthPage.fillEmailField(email); + await emailAuthPage.fillPasswordField(shortPassword); + await emailAuthPage.fillConfirmPasswordField(shortPassword); + + // Attempt to submit (should be blocked by HTML5 minLength validation) + await emailAuthPage.clickCreateAccountBtn(); + + // Verify modal is still open (form blocked by HTML5 validation) + const isModalVisible = await homePage.isLoginModalVisible(); + expect(isModalVisible).toBeTruthy(); + + // User should still not be logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeFalsy(); + }); + + test('should successfully sign in with email after signup via API', async ({ request }) => { + // Step 1: Create user via API (setup) + const email = generateTestEmail('signin-test'); + const firstName = 'Jane'; + const lastName = 'Smith'; + const password = 'SecurePass456!'; + + const signupApi = new ApiCalls(); + const signupResponse = await signupApi.signupWithEmail(firstName, lastName, email, password, request); + expect(signupResponse.success).toBeTruthy(); + + // Step 2: Test signin via UI + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Navigate to sign in with email form (modal opens in signin mode by default) + await emailAuthPage.clickEmailAuthBtn(); + await emailAuthPage.waitForEmailSigninFormVisible(); + + // Fill in signin credentials + await emailAuthPage.fillEmailFieldSignin(email); + await emailAuthPage.fillPasswordFieldSignin(password); + + // Submit signin form + await emailAuthPage.clickSignInBtn(); + + // Wait for successful signin (modal should close) + await homePage.waitForLoginModalHidden(10000); + + // Verify user is logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeTruthy(); + + // Verify correct user email + await homePage.clickOnUserMenu(); + const userEmail = await emailAuthPage.getUserEmail(); + expect(userEmail).toBe(email); + }); + + test('should fail signin with wrong password', async ({ request }) => { + // Step 1: Create user via API + const email = generateTestEmail('wrong-password'); + const firstName = 'Bob'; + const lastName = 'Johnson'; + const correctPassword = 'CorrectPass789!'; + const wrongPassword = 'WrongPass123!'; + + const signupApi = new ApiCalls(); + await signupApi.signupWithEmail(firstName, lastName, email, correctPassword, request); + + // Step 2: Attempt signin with wrong password + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Navigate to sign in with email form (modal opens in signin mode by default) + await emailAuthPage.clickEmailAuthBtn(); + await emailAuthPage.waitForEmailSigninFormVisible(); + + // Fill with wrong password + await emailAuthPage.fillEmailFieldSignin(email); + await emailAuthPage.fillPasswordFieldSignin(wrongPassword); + + // Attempt signin + await emailAuthPage.clickSignInBtn(); + + // Verify error message is shown + await emailAuthPage.waitForSigninErrorVisible(); + const errorText = await emailAuthPage.getSigninErrorText(); + expect(errorText).toBeTruthy(); + + // User should not be logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeFalsy(); + }); + + test('should fail signin with non-existent email', async () => { + const nonExistentEmail = generateTestEmail('non-existent'); + const password = 'SomePassword123!'; + + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Navigate to sign in with email form (modal opens in signin mode by default) + await emailAuthPage.clickEmailAuthBtn(); + await emailAuthPage.waitForEmailSigninFormVisible(); + + // Fill with non-existent credentials + await emailAuthPage.fillEmailFieldSignin(nonExistentEmail); + await emailAuthPage.fillPasswordFieldSignin(password); + + // Attempt signin + await emailAuthPage.clickSignInBtn(); + + // Verify error message is shown + await emailAuthPage.waitForSigninErrorVisible(); + const errorText = await emailAuthPage.getSigninErrorText(); + expect(errorText).toBeTruthy(); + + // User should not be logged in + const isLoggedIn = await homePage.isLoggedIn(); + expect(isLoggedIn).toBeFalsy(); + }); + + test('should toggle between signup and signin modes', async () => { + // Login modal appears automatically for unauthenticated users + await homePage.waitForLoginModalVisible(); + + // Modal opens in signin mode by default, switch to signup mode + // Note: switchMode() also shows the email form automatically + await emailAuthPage.clickSwitchModeLink(); + + // Wait for signup form (shown automatically after switch) + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Verify signup form is visible + let isSignupFormVisible = await emailAuthPage.isSignupFormVisible(); + expect(isSignupFormVisible).toBeTruthy(); + + // Switch to signin + await emailAuthPage.clickSwitchToSigninLink(); + await emailAuthPage.waitForEmailSigninFormVisible(); + + // Verify signin form is visible + const isSigninFormVisible = await emailAuthPage.isSigninFormVisible(); + expect(isSigninFormVisible).toBeTruthy(); + + // Switch back to signup + await emailAuthPage.clickSwitchToSignupLink(); + await emailAuthPage.waitForEmailSignupFormVisible(); + + // Verify signup form is visible again + isSignupFormVisible = await emailAuthPage.isSignupFormVisible(); + expect(isSignupFormVisible).toBeTruthy(); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 7f9f78fa..fc0a506b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ testMatch: /.*\.setup\.ts/, }, + // Authenticated tests (most tests) { name: 'chromium', use: { @@ -51,6 +52,7 @@ export default defineConfig({ storageState: 'e2e/.auth/user.json', }, dependencies: ['setup'], + testIgnore: '**/emailAuth.spec.ts', // Exclude email auth tests }, { @@ -61,6 +63,26 @@ export default defineConfig({ storageState: 'e2e/.auth/user.json', }, dependencies: ['setup'], + testIgnore: '**/emailAuth.spec.ts', // Exclude email auth tests + }, + + // Unauthenticated tests (for testing signup/signin flows) + { + name: 'chromium-unauthenticated', + use: { + ...devices['Desktop Chrome'], + // No storageState - tests run without authentication + }, + testMatch: '**/emailAuth.spec.ts', // Only run email auth tests + }, + + { + name: 'firefox-unauthenticated', + use: { + ...devices['Desktop Firefox'], + // No storageState - tests run without authentication + }, + testMatch: '**/emailAuth.spec.ts', // Only run email auth tests }, // {