From 730ed1a76a233955ae32c8a7d3365d89bd593a10 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Mon, 2 Feb 2026 00:15:31 -0700 Subject: [PATCH] feat: add headless email auth, account settings, and push notification cleanup --- bun.lock | 1 + package.json | 3 +- src/app/api/auth/email/route.ts | 86 +++++++ src/app/api/auth/login/route.ts | 63 +++-- src/app/api/auth/reset-password/route.ts | 30 +++ src/app/api/cron/reminders/route.ts | 19 +- src/app/login/page.tsx | 116 +-------- src/app/settings/page.tsx | 112 +++++++++ src/components/SideMenu.tsx | 6 + src/components/UnifiedLogin.tsx | 298 +++++++++++++++++++++++ src/lib/d1.ts | 9 + src/lib/workos.ts | 11 +- 12 files changed, 611 insertions(+), 143 deletions(-) create mode 100644 src/app/api/auth/email/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts create mode 100644 src/app/settings/page.tsx create mode 100644 src/components/UnifiedLogin.tsx diff --git a/bun.lock b/bun.lock index 0bbf64c..4676fbe 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "styled-jsx": "^5.1.6", "tailwind-merge": "^3.4.0", "web-push": "^3.6.7", + "yaml": "^2.8.2", }, "devDependencies": { "@cloudflare/workers-types": "^4.20250121.0", diff --git a/package.json b/package.json index 3fb4244..f5316a0 100644 --- a/package.json +++ b/package.json @@ -40,9 +40,10 @@ "react-day-picker": "^9.13.0", "react-dom": "19.2.3", "recharts": "^3.7.0", + "styled-jsx": "^5.1.6", "tailwind-merge": "^3.4.0", "web-push": "^3.6.7", - "styled-jsx": "^5.1.6" + "yaml": "^2.8.2" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250121.0", diff --git a/src/app/api/auth/email/route.ts b/src/app/api/auth/email/route.ts new file mode 100644 index 0000000..f3c8b70 --- /dev/null +++ b/src/app/api/auth/email/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { workos, clientId } from '@/lib/workos'; +import { setSession } from '@/lib/session'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json() as { + email?: string; + password?: string; + firstName?: string; + lastName?: string; + type?: 'signup' | 'login'; + stayLoggedIn?: boolean; + }; + const { email, password, firstName, lastName, type, stayLoggedIn } = body; + + if (!email || !password) { + return NextResponse.json({ error: 'Email and password are required' }, { status: 400 }); + } + + let user; + + // Handle Signup + if (type === 'signup') { + try { + await workos.userManagement.createUser({ + email, + password, + firstName, + lastName, + emailVerified: false, // WorkOS sends verification email automatically if configured + }); + } catch (error: any) { + // Handle user already exists + if (error.code === 'user_already_exists') { + return NextResponse.json({ error: 'A user with this email already exists.' }, { status: 409 }); + } + throw error; + } + } + + // Authenticate (for both login and after signup) + try { + const response = await workos.userManagement.authenticateWithPassword({ + email, + password, + clientId, + }); + + user = response.user; + + // extensive logging to debug structure if needed + // console.log('Auth response:', JSON.stringify(response, null, 2)); + + // Create session + await setSession({ + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + profilePictureUrl: user.profilePictureUrl, + }, + accessToken: response.accessToken, // Note: SDK returns camelCase + refreshToken: response.refreshToken, + stayLoggedIn: !!stayLoggedIn, + }); + + return NextResponse.json({ success: true, user }); + + } catch (error: any) { + console.error('Authentication error:', error); + + // Map WorkOS errors to user-friendly messages + if (error.code === 'invalid_credentials' || error.message?.includes('Invalid credentials')) { + return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 }); + } + + return NextResponse.json({ error: error.message || 'Authentication failed' }, { status: 400 }); + } + + } catch (error: any) { + console.error('Login API error:', error); + return NextResponse.json({ error: 'An internal error occurred.' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d9223e4..d2e9ac7 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -5,31 +5,48 @@ import { cookies } from 'next/headers'; const VALID_PROVIDERS = ['GoogleOAuth', 'AppleOAuth', 'GitHubOAuth']; export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const provider = searchParams.get('provider') as OAuthProvider | 'authkit' | null; - const stayLoggedIn = searchParams.get('stayLoggedIn') === 'true'; + try { + const searchParams = request.nextUrl.searchParams; + const provider = searchParams.get('provider') as OAuthProvider | 'authkit' | null; + const stayLoggedIn = searchParams.get('stayLoggedIn') === 'true'; - // Store the stay logged in preference in a cookie - const cookieStore = await cookies(); - cookieStore.set('stay_logged_in', stayLoggedIn.toString(), { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, // 10 minutes for the auth flow - path: '/', - }); + // Store the stay logged in preference in a cookie + const cookieStore = await cookies(); + cookieStore.set('stay_logged_in', stayLoggedIn.toString(), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 10, // 10 minutes for the auth flow + path: '/', + }); - // If no provider or 'authkit', redirect to hosted AuthKit (email/password, phone) - if (!provider || provider === 'authkit') { - const authUrl = getAuthKitUrl(); + // If no provider or 'authkit', redirect to hosted AuthKit (email/password, phone) + if (!provider || provider === 'authkit') { + const authUrl = getAuthKitUrl(); + console.log('AuthKit URL generated:', authUrl); + + if (!authUrl || typeof authUrl !== 'string') { + return NextResponse.json({ + error: 'Failed to generate AuthKit URL', + debug: { authUrl, hasClientId: !!process.env.WORKOS_CLIENT_ID, hasRedirectUri: !!process.env.WORKOS_REDIRECT_URI } + }, { status: 500 }); + } + + return NextResponse.redirect(authUrl); + } + + // Validate OAuth provider + if (!VALID_PROVIDERS.includes(provider)) { + return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); + } + + const authUrl = getAuthorizationUrl(provider); return NextResponse.redirect(authUrl); + } catch (error) { + console.error('Login route error:', error); + return NextResponse.json({ + error: 'Authentication error', + message: String(error) + }, { status: 500 }); } - - // Validate OAuth provider - if (!VALID_PROVIDERS.includes(provider)) { - return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); - } - - const authUrl = getAuthorizationUrl(provider); - return NextResponse.redirect(authUrl); } diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..4acf46f --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '@/lib/session'; + +// Send password reset email to current user +// NOTE: WorkOS AuthKit handles password reset through the hosted UI flow +// Users can reset their password by clicking "Forgot password" on the login page +export async function POST() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + // For WorkOS AuthKit, password reset is handled through the hosted UI + // The user should be directed to re-authenticate via the login page + // where they can click "Forgot password" + + // Return a message directing the user to the login page + return NextResponse.json({ + success: true, + message: 'To reset your password, please log out and use the "Forgot Password" option on the login page.', + redirectTo: '/login' + }); + } catch (error) { + console.error('Password reset error:', error); + return NextResponse.json({ + error: 'An error occurred. Please try again.' + }, { status: 500 }); + } +} diff --git a/src/app/api/cron/reminders/route.ts b/src/app/api/cron/reminders/route.ts index c149920..3ceb843 100644 --- a/src/app/api/cron/reminders/route.ts +++ b/src/app/api/cron/reminders/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import webPush from 'web-push'; -import { getUsersForRemindersD1, updateLastNotifiedD1 } from '@/lib/d1'; +import { getUsersForRemindersD1, updateLastNotifiedD1, deletePushSubscriptionD1 } from '@/lib/d1'; // Configure web-push - Helper called inside handler to ensure env is ready function ensureVapidConfig() { @@ -207,8 +207,21 @@ export async function GET(request: NextRequest) { } catch (err) { console.error(`Failed to process user ${user.userId}:`, err); - processed.push({ userId: user.userId, status: 'error', error: String(err) }); - // If 410 Gone, we should delete subscription, but for now just log + + // Check for 410 Gone - subscription expired, delete from DB + const errorStr = String(err); + if (errorStr.includes('410') || errorStr.includes('Gone') || errorStr.includes('expired')) { + console.log(`Subscription expired for user ${user.userId}, cleaning up...`); + try { + await deletePushSubscriptionD1(user.userId); + processed.push({ userId: user.userId, status: 'expired_cleaned', error: 'Subscription expired and removed' }); + } catch (deleteErr) { + console.error(`Failed to delete expired subscription for ${user.userId}:`, deleteErr); + processed.push({ userId: user.userId, status: 'error', error: String(err) }); + } + } else { + processed.push({ userId: user.userId, status: 'error', error: String(err) }); + } } } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e298709..3e1d04f 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,21 +1,9 @@ 'use client'; -import { useState } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Label } from '@/components/ui/label'; -import { Mail, Phone } from 'lucide-react'; - -type AuthProvider = 'GoogleOAuth' | 'AppleOAuth' | 'GitHubOAuth' | 'authkit'; +import { UnifiedLogin } from '@/components/UnifiedLogin'; export default function LoginPage() { - const [stayLoggedIn, setStayLoggedIn] = useState(false); - - const handleLogin = (provider: AuthProvider) => { - window.location.href = `/api/auth/login?provider=${provider}&stayLoggedIn=${stayLoggedIn}`; - }; - return (
{/* Background Orbs */} @@ -36,106 +24,8 @@ export default function LoginPage() { Your companion to a smoke-free life - - {/* OAuth Providers */} -
- - - - - -
- - {/* Divider */} -
-
-
-
-
- or -
-
- - {/* Email & Phone Options */} -
- - - -
- -
- setStayLoggedIn(checked === true)} - className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700" - /> - -
- -
-

- By continuing, you agree to our Terms & Privacy Policy -

-
+ +
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..6f55d61 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft, Mail, Lock, LogOut, ExternalLink } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useTheme } from '@/lib/theme-context'; +import { cn } from '@/lib/utils'; + +export default function SettingsPage() { + const router = useRouter(); + const { theme } = useTheme(); + + const handlePasswordReset = () => { + // Log out and redirect to login page where they can use "Forgot Password" + window.location.href = '/api/auth/logout?redirect=/login'; + }; + + return ( +
+ {/* Header */} +
+
+ +

Account Settings

+
+
+ +
+ {/* Password Reset Card */} + + + + + Change Password + + + Reset your password through our secure authentication provider. + + + +
+

+ To change your password, you'll be logged out and redirected to the login page. + Use the "Forgot Password" option to receive a reset link via email. +

+
+ + +
+
+ + {/* Email Info Card */} + + + + + Email & Account + + + Your authentication is managed securely through WorkOS. + + + +

+ To change your email address or manage connected accounts (Google, Apple, GitHub), + contact support or create a new account with your preferred email. +

+
+
+
+
+ ); +} diff --git a/src/components/SideMenu.tsx b/src/components/SideMenu.tsx index 900d5ad..a018550 100644 --- a/src/components/SideMenu.tsx +++ b/src/components/SideMenu.tsx @@ -140,6 +140,12 @@ export function SideMenu({ isOpen, onClose, user, userName }: SideMenuProps) { "p-4 border-t", theme === 'light' ? "border-slate-100" : "border-white/5" )}> + handleNavigate('/settings')} + color="blue" + /> + + + + + + {/* Divider */} +
+
+
+
+
+ or +
+
+ + + + + +
+

+ By continuing, you agree to our Terms & Privacy Policy +

+
+
+ ); + } + + return ( +
+
+

+ {isSignup ? 'Create Account' : 'Welcome Back'} +

+ +
+ +
+ {error && ( +
+ + {error} +
+ )} + + {isSignup && ( +
+
+ + setFormData({ ...formData, firstName: e.target.value })} + required={isSignup} + className="bg-slate-50 dark:bg-slate-800/50" + /> +
+
+ + setFormData({ ...formData, lastName: e.target.value })} + required={isSignup} + className="bg-slate-50 dark:bg-slate-800/50" + /> +
+
+ )} + +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + required + className="pl-10 bg-slate-50 dark:bg-slate-800/50" + /> +
+
+ +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + required + minLength={8} + className="pl-10 bg-slate-50 dark:bg-slate-800/50" + /> +
+
+ + + + + + +
+ +
+
+ ); +} diff --git a/src/lib/d1.ts b/src/lib/d1.ts index ca7f74f..9e61ab4 100644 --- a/src/lib/d1.ts +++ b/src/lib/d1.ts @@ -439,6 +439,15 @@ export async function upsertPushSubscriptionD1( return getPushSubscriptionD1(userId); } +export async function deletePushSubscriptionD1(userId: string): Promise { + const db = getD1(); + if (!db) return; + + await db.prepare( + 'DELETE FROM PushSubscriptions WHERE userId = ?' + ).bind(userId).run(); +} + // ============ MOOD TRACKER ============ export interface MoodEntryRow { diff --git a/src/lib/workos.ts b/src/lib/workos.ts index 3de088a..aa7961e 100644 --- a/src/lib/workos.ts +++ b/src/lib/workos.ts @@ -15,9 +15,14 @@ export function getAuthorizationUrl(provider: OAuthProvider) { } // Get AuthKit hosted login URL (for email/password and phone) +// Note: We manually construct this URL with provider=authkit export function getAuthKitUrl() { - return workos.userManagement.getAuthorizationUrl({ - clientId, - redirectUri: process.env.WORKOS_REDIRECT_URI!, + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: process.env.WORKOS_REDIRECT_URI!, + response_type: 'code', + provider: 'authkit', }); + + return `https://api.workos.com/user_management/authorize?${params.toString()}`; }