From 69be398cd96891c49e0eaffc8244e54260b8ef39 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Thu, 20 Nov 2025 22:11:09 -0800 Subject: [PATCH] testing hehehe --- README.md | 49 +++++++++++++ package-lock.json | 114 ++++++++++++++++++++++++++++++ package.json | 4 ++ server.ts | 110 +++++++++++++++++++++++++++- sql/master_schema_rollup.sql | 2 +- src/App.tsx | 40 +++++++++++ src/components/ProfileManager.tsx | 53 +++++++++++--- src/pages/VoiceLabPage.tsx | 3 +- src/services/db.ts | 2 +- 9 files changed, 362 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0eb88277..1b31a481 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,52 @@ sudo nginx -t sudo systemctl reload nginx actually the proper change was to do this in the /etc/nginx/sites-available/flyer-crawler.projectium.com file + +## for OAuth + +1. Get Google OAuth Credentials +This is a crucial step that you must do outside the codebase: + +Go to the Google Cloud Console. + +Create a new project (or select an existing one). + +In the navigation menu, go to APIs & Services > Credentials. + +Click Create Credentials > OAuth client ID. + +Select Web application as the application type. + +Under Authorized redirect URIs, click ADD URI and enter the URL where Google will redirect users back to your server. For local development, this will be: http://localhost:3001/api/auth/google/callback. + +Click Create. You will be given a Client ID and a Client Secret. + +Add these credentials to your .env file at the project root: + +plaintext +GOOGLE_CLIENT_ID="your-client-id-from-google" +GOOGLE_CLIENT_SECRET="your-client-secret-from-google" + +2. Get GitHub OAuth Credentials +You'll need to obtain a Client ID and Client Secret from GitHub: + +Go to your GitHub profile settings. + +Navigate to Developer settings > OAuth Apps. + +Click New OAuth App. + +Fill in the required fields: + +Application name: A descriptive name for your app (e.g., "Flyer Crawler"). +Homepage URL: The base URL of your application (e.g., http://localhost:5173 for local development). +Authorization callback URL: This is where GitHub will redirect users after they authorize your app. For local development, this will be: http://localhost:3001/api/auth/github/callback. +Click Register application. + +You will be given a Client ID and a Client Secret. + +Add these credentials to your .env file at the project root: + +plaintext +GITHUB_CLIENT_ID="your-github-client-id" +GITHUB_CLIENT_SECRET="your-github-client-secret" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index eb0ad5ca..5b1bcb9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "multer": "^2.0.2", "nodemailer": "^7.0.10", "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfjs-dist": "^5.4.394", @@ -41,6 +43,8 @@ "@types/node": "^24.10.1", "@types/nodemailer": "^7.0.4", "@types/passport": "^1.0.17", + "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/pg": "^8.15.6", @@ -4199,6 +4203,16 @@ "@types/node": "*" } }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -4209,6 +4223,30 @@ "@types/express": "*" } }, + "node_modules/@types/passport-github2": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@types/passport-github2/-/passport-github2-1.2.9.tgz", + "integrity": "sha512-/nMfiPK2E6GKttwBzwj0Wjaot8eHrM57hnWxu52o6becr5/kXlH/4yE2v2rh234WGvSgEEzIII02Nc5oC5xEHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -4232,6 +4270,18 @@ "@types/passport-strategy": "*" } }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -5457,6 +5507,15 @@ ], "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", @@ -10132,6 +10191,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10386,6 +10451,29 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-github2": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", + "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -10407,6 +10495,26 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -12793,6 +12901,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index e0ab80e9..beea8d84 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "multer": "^2.0.2", "nodemailer": "^7.0.10", "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pdfjs-dist": "^5.4.394", @@ -45,6 +47,8 @@ "@types/node": "^24.10.1", "@types/nodemailer": "^7.0.4", "@types/passport": "^1.0.17", + "@types/passport-github2": "^1.2.9", + "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/pg": "^8.15.6", diff --git a/server.ts b/server.ts index 5c2c6aa7..ae98fa75 100644 --- a/server.ts +++ b/server.ts @@ -1,6 +1,8 @@ import express, { Request, Response, NextFunction } from 'express'; import passport from 'passport'; import { Strategy as LocalStrategy } from 'passport-local'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { Strategy as GitHubStrategy } from 'passport-github2'; import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; import bcrypt from 'bcrypt'; import zxcvbn from 'zxcvbn'; @@ -14,7 +16,7 @@ import multer from 'multer'; import * as db from './src/services/db'; import { logger } from './src/services/logger'; // This import is correct import * as aiService from './src/services/aiService.server'; // Import the new server-side AI service -import { sendPasswordResetEmail } from './src/services/emailService'; +import { sendPasswordResetEmail, sendWelcomeEmail } from './src/services/emailService'; import { UserProfile, ShoppingListItem } from './src/types'; // Load environment variables from a .env file at the root of your project @@ -132,6 +134,112 @@ passport.use(new LocalStrategy( } )); +// --- Passport Google OAuth 2.0 Strategy --- +passport.use(new GoogleStrategy({ + clientID: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + callbackURL: '/api/auth/google/callback', // Must match the one in Google Cloud Console + scope: ['profile', 'email'] + }, + async (accessToken, refreshToken, profile, done) => { + try { + const email = profile.emails?.[0]?.value; + if (!email) { + return done(new Error("No email found in Google profile."), false); + } + + // Check if user already exists in our database + let user = await db.findUserByEmail(email); + + if (user) { + // User exists, proceed to log them in. + logger.info(`Google OAuth successful for existing user: ${email}`); + const { password_hash, ...userWithoutHash } = user; + return done(null, userWithoutHash); + } else { + // User does not exist, create a new account for them. + logger.info(`Google OAuth: creating new user for email: ${email}`); + + // Since this is an OAuth user, they don't have a password. + // We pass `null` for the password hash. + const newUser = await db.createUser(email, null, { + full_name: profile.displayName, + avatar_url: profile.photos?.[0]?.value + }); + + // Send a welcome email to the new user + try { + await sendWelcomeEmail(email, profile.displayName); + } catch (emailError) { + logger.error(`Failed to send welcome email to new Google user ${email}`, { error: emailError }); + // Don't block the login flow if email fails. + } + + // The `createUser` function returns the user object without the password hash. + return done(null, newUser); + } + } catch (err) { + logger.error('Error during Google authentication strategy:', { error: err }); + return done(err, false); + } + } +)); + +// --- Passport GitHub OAuth 2.0 Strategy --- +passport.use(new GitHubStrategy({ + clientID: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + callbackURL: '/api/auth/github/callback', // Must match the one in GitHub OAuth App settings + scope: ['user:email'] // Request email access + }, + async (accessToken, refreshToken, profile, done) => { + try { + const email = profile.emails?.[0]?.value; + if (!email) { + return done(new Error("No public email found in GitHub profile. Please ensure your primary email is public or add one."), false); + } + + // Check if user already exists in our database + let user = await db.findUserByEmail(email); + + if (user) { + // User exists, proceed to log them in. + logger.info(`GitHub OAuth successful for existing user: ${email}`); + const { password_hash, ...userWithoutHash } = user; + return done(null, userWithoutHash); + } else { + // User does not exist, create a new account for them. + logger.info(`GitHub OAuth: creating new user for email: ${email}`); + + // Since this is an OAuth user, they don't have a password. + // We pass `null` for the password hash. + const newUser = await db.createUser(email, null, { + full_name: profile.displayName || profile.username, // GitHub profile might not have displayName + avatar_url: profile.photos?.[0]?.value + }); + + // Send a welcome email to the new user + try { + await sendWelcomeEmail(email, profile.displayName || profile.username); + } catch (emailError) { + logger.error(`Failed to send welcome email to new GitHub user ${email}`, { error: emailError }); + // Don't block the login flow if email fails. + } + + // The `createUser` function returns the user object without the password hash. + return done(null, newUser); + } + } catch (err) { + logger.error('Error during GitHub authentication strategy:', { error: err }); + return done(err, false); + } + } +)); + +// Serialize and deserialize user are not needed for JWT-based sessions +// passport.serializeUser((user, done) => done(null, user)); +// passport.deserializeUser((user, done) => done(null, user as any)); + // --- Passport JWT Strategy (for protecting API routes) --- const jwtOptions = { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Expect JWT in 'Authorization: Bearer ' header diff --git a/sql/master_schema_rollup.sql b/sql/master_schema_rollup.sql index 1f38d7c7..13a91f08 100644 --- a/sql/master_schema_rollup.sql +++ b/sql/master_schema_rollup.sql @@ -32,7 +32,7 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- For generating UUIDs CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, + password_hash TEXT, refresh_token TEXT, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL diff --git a/src/App.tsx b/src/App.tsx index d2f84771..46318496 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -248,6 +248,46 @@ function App() { checkAuthToken(); }, [fetchWatchedItems, fetchShoppingLists]); // Add callbacks to dependency array. + // Effect to handle the token from Google OAuth redirect + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const googleToken = urlParams.get('googleAuthToken'); + + if (googleToken) { + logger.info('Received Google Auth token from URL. Authenticating...'); + // The token is already a valid access token from our server. + // We can use it to fetch the user profile and complete the login flow. + localStorage.setItem('authToken', googleToken); + getAuthenticatedUserProfile() + .then(userProfile => { + handleLoginSuccess(userProfile.user, googleToken); + }) + .catch(err => logger.error('Failed to log in with Google token', { error: err })); + + // Clean the token from the URL + window.history.replaceState({}, document.title, "/"); + } + + const githubToken = urlParams.get('githubAuthToken'); + if (githubToken) { + logger.info('Received GitHub Auth token from URL. Authenticating...'); + // The token is already a valid access token from our server. + // We can use it to fetch the user profile and complete the login flow. + localStorage.setItem('authToken', githubToken); + getAuthenticatedUserProfile() + .then(userProfile => { + handleLoginSuccess(userProfile.user, githubToken); + }) + .catch(err => { + logger.error('Failed to log in with GitHub token', { error: err }); + // Optionally, redirect to a page with an error message + // navigate('/login?error=github_auth_failed'); + }); + + // Clean the token from the URL + window.history.replaceState({}, document.title, "/"); + } + }, [handleLoginSuccess]); useEffect(() => { if (isReady) { diff --git a/src/components/ProfileManager.tsx b/src/components/ProfileManager.tsx index ebb15ee6..2c276133 100644 --- a/src/components/ProfileManager.tsx +++ b/src/components/ProfileManager.tsx @@ -5,8 +5,10 @@ import { notifySuccess, notifyError } from '../services/notificationService'; import { logger } from '../services/logger'; import { LoadingSpinner } from './LoadingSpinner'; import { XMarkIcon } from './icons/XMarkIcon'; +import { GoogleIcon } from './icons/GoogleIcon'; +import { GithubIcon } from './icons/GithubIcon'; import { ConfirmationModal } from './ConfirmationModal'; -import { User } from '../types'; // Import User type for props +import { User } from '../types'; import { PasswordInput } from './PasswordInput'; type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED'; @@ -265,19 +267,16 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, } }; - const handleOAuthSignIn = async (provider: 'google' | 'github') => { + const handleOAuthSignIn = (provider: 'google' | 'github') => { + // Redirect to the backend OAuth initiation route + // The backend will then handle the redirect to the OAuth provider + // and eventually redirect back to the frontend with a token. + window.location.href = `/api/auth/${provider}`; + // Set authLoading to true to show a spinner while redirecting setAuthLoading(true); - const message = `Sign in with ${provider} is not yet implemented.`; - notifyError(message); - logger.warn(`OAuth sign-in attempt with ${provider} failed: not implemented.`); - - // We set a timeout to turn off the loading indicator and clear the error - // so the user can try another method. - setTimeout(() => { - setAuthLoading(false); - }, 3000); // This was a closing parenthesis for the setTimeout }; + if (!isOpen) return null; return ( @@ -383,6 +382,24 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, {isRegistering ? 'Already have an account? Sign In' : "Don't have an account? Register"} +
+ +
+ + +
) ) : ( @@ -458,6 +475,20 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, +
+

Link Social Accounts

+

Connect other accounts for easier sign-in.

+
+ + +
+
)} diff --git a/src/pages/VoiceLabPage.tsx b/src/pages/VoiceLabPage.tsx index 011186a0..11b97ab6 100644 --- a/src/pages/VoiceLabPage.tsx +++ b/src/pages/VoiceLabPage.tsx @@ -3,7 +3,8 @@ import { generateSpeechFromText, startVoiceSession } from '../services/aiApiClie import { logger } from '../services/logger'; import { notifyError } from '../services/notificationService'; import { LoadingSpinner } from '../components/LoadingSpinner'; -import { SpeakerWaveIcon, MicrophoneIcon } from '../components/icons/HeroIcons'; +import { SpeakerWaveIcon } from '../components/icons/SpeakerWaveIcon'; +import { MicrophoneIcon } from '../components/icons/MicrophoneIcon'; export const VoiceLabPage: React.FC = () => { const [textToSpeak, setTextToSpeak] = useState('Hello! This is a test of the text-to-speech generation.'); diff --git a/src/services/db.ts b/src/services/db.ts index beb6136d..8e0692f8 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -54,7 +54,7 @@ export async function findUserByEmail(email: string): Promise { // Use a client from the pool to run multiple queries in a transaction