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 */}
-
-
handleLogin('GoogleOAuth')}
- >
-
-
-
-
-
-
- Continue with Google
-
-
-
handleLogin('AppleOAuth')}
- >
-
-
-
- Continue with Apple
-
-
-
handleLogin('GitHubOAuth')}
- >
-
-
-
- Continue with GitHub
-
-
-
- {/* Divider */}
-
-
- {/* Email & Phone Options */}
-
-
handleLogin('authkit')}
- >
-
- Continue with Email
-
-
-
handleLogin('authkit')}
- >
-
- Continue with Phone
-
-
-
-
- setStayLoggedIn(checked === true)}
- className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700"
- />
-
- Keep me logged in on this device
-
-
-
-
-
- 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 */}
+
+
+
router.back()}
+ className="rounded-full"
+ >
+
+
+
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.
+
+
+
+
+
+ Log Out to Reset Password
+
+
+
+
+ {/* 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"
+ />
void;
+}
+
+export function UnifiedLogin({ onSuccess }: UnifiedLoginProps) {
+ const router = useRouter();
+ const [isSignup, setIsSignup] = useState(false);
+ const [showEmailForm, setShowEmailForm] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [stayLoggedIn, setStayLoggedIn] = useState(false);
+
+ const [formData, setFormData] = useState({
+ email: '',
+ password: '',
+ firstName: '',
+ lastName: '',
+ });
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError(null);
+
+ try {
+ const res = await fetch('/api/auth/email', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ...formData,
+ type: isSignup ? 'signup' : 'login',
+ stayLoggedIn,
+ }),
+ });
+
+ const data = await res.json() as { error?: string };
+
+ if (!res.ok) {
+ throw new Error(data.error || 'Authentication failed');
+ }
+
+ // Success
+ if (onSuccess) onSuccess();
+ router.push('/home');
+ router.refresh();
+ } catch (err: any) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleOAuthLogin = (provider: string) => {
+ window.location.href = `/api/auth/login?provider=${provider}&stayLoggedIn=${stayLoggedIn}`;
+ };
+
+ const toggleMode = () => {
+ setIsSignup(!isSignup);
+ setError(null);
+ };
+
+ // Checkbox Component to reuse
+ const StayLoggedInCheckbox = () => (
+
+ setStayLoggedIn(checked === true)}
+ className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700"
+ />
+
+ Keep me logged in on this device
+
+
+ );
+
+ if (!showEmailForm) {
+ return (
+
+
handleOAuthLogin('GoogleOAuth')}
+ >
+
+
+
+
+
+
+ Continue with Google
+
+
+
handleOAuthLogin('AppleOAuth')}
+ >
+
+
+
+ Continue with Apple
+
+
+
handleOAuthLogin('GitHubOAuth')}
+ >
+
+
+
+ Continue with GitHub
+
+
+ {/* Divider */}
+
+
+
setShowEmailForm(true)}
+ >
+
+ Continue with Email
+
+
+
+
+
+
+ By continuing, you agree to our Terms & Privacy Policy
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {isSignup ? 'Create Account' : 'Welcome Back'}
+
+ setShowEmailForm(false)}
+ className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
+ >
+ Back
+
+
+
+
+
+
+
+ {isSignup ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
+
+
+
+ );
+}
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()}`;
}