From a255442616eacdc4496b6466508ef5060854605b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 17:38:18 +0000 Subject: [PATCH] Refactor identity management --- src/components/settings/SecurityTab.tsx | 103 ++------------ src/hooks/useAuth.tsx | 68 --------- src/lib/identityService.ts | 174 ++---------------------- src/lib/sessionFlags.ts | 22 --- src/pages/AuthCallback.tsx | 110 --------------- 5 files changed, 21 insertions(+), 456 deletions(-) diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index 2e98fa63..e33abd54 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -16,8 +16,7 @@ import { checkDisconnectSafety, disconnectIdentity, connectIdentity, - hasOrphanedPassword, - triggerOrphanedPasswordConfirmation + addPasswordToAccount } from '@/lib/identityService'; import type { UserIdentity, OAuthProvider } from '@/types/identity'; import { toast as sonnerToast } from '@/components/ui/sonner'; @@ -33,26 +32,12 @@ export function SecurityTab() { const [disconnectingProvider, setDisconnectingProvider] = useState(null); const [hasPassword, setHasPassword] = useState(false); const [addingPassword, setAddingPassword] = useState(false); - const [showOrphanedPasswordOption, setShowOrphanedPasswordOption] = useState(false); // Load user identities on mount useEffect(() => { loadIdentities(); }, []); - useEffect(() => { - const checkOrphanedPassword = async () => { - if (!hasPassword) { - const isOrphaned = await hasOrphanedPassword(); - setShowOrphanedPasswordOption(isOrphaned); - } - }; - - if (!loadingIdentities) { - checkOrphanedPassword(); - } - }, [hasPassword, loadingIdentities]); - const loadIdentities = async () => { try { setLoadingIdentities(true); @@ -140,50 +125,11 @@ export function SecurityTab() { const handleAddPassword = async () => { setAddingPassword(true); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user?.email) { - toast({ - title: "No Email Found", - description: "Your account doesn't have an email address associated with it.", - variant: "destructive" - }); - setAddingPassword(false); - return; - } - - // Trigger password reset email directly (no modal needed!) - const { error } = await supabase.auth.resetPasswordForEmail( - user.email, - { - redirectTo: `${window.location.origin}/auth/callback?action=password-setup-direct` - } - ); - - if (error) { - toast({ - title: "Failed to Send Email", - description: error.message, - variant: "destructive" - }); - } else { - sonnerToast.success("Password Reset Email Sent!", { - description: "Check your email for a password reset link. Click it to set your password on ThrillWiki.", - duration: 15000, - }); - } - - setAddingPassword(false); - }; - - const handleSendConfirmationEmail = async () => { - setAddingPassword(true); - - const result = await triggerOrphanedPasswordConfirmation('security_settings'); + const result = await addPasswordToAccount(); if (result.success) { - sonnerToast.success("Reset Email Sent!", { - description: "Check your email for a password reset link from Supabase to activate your password authentication. You'll also receive a notification email from ThrillWiki.", + sonnerToast.success("Password Setup Email Sent!", { + description: `Check ${result.email} for a password reset link. Click it to set your password.`, duration: 15000, }); } else { @@ -247,39 +193,16 @@ export function SecurityTab() { Change Password ) : ( - <> - - - {showOrphanedPasswordOption && ( - + )} diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 01020e8b..8365bbc9 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -113,74 +113,6 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { } else { setAal(null); } - - // Check for orphaned password on SIGNED_IN events - if (event === 'SIGNED_IN' && session?.user) { - try { - // Import sessionFlags - const { isOrphanedPasswordDismissed, setOrphanedPasswordDismissed } = - await import('@/lib/sessionFlags'); - - // Skip if already shown in this auth cycle or dismissed this session - if (orphanedPasswordToastShownRef.current || isOrphanedPasswordDismissed()) { - authLog('[Auth] Skipping orphaned password toast - already shown or dismissed'); - return; - } - - // Import identityService functions - const { getUserIdentities, hasOrphanedPassword, triggerOrphanedPasswordConfirmation } = - await import('@/lib/identityService'); - - // Check if user has email identity - const identities = await getUserIdentities(); - const hasEmailIdentity = identities.some(i => i.provider === 'email'); - - // If no email identity but has other identities, check for orphaned password - if (!hasEmailIdentity && identities.length > 0) { - const isOrphaned = await hasOrphanedPassword(); - - if (isOrphaned) { - // Mark as shown to prevent duplicates - orphanedPasswordToastShownRef.current = true; - - // Show persistent toast with Resend button - const { toast: sonnerToast } = await import('sonner'); - - sonnerToast.warning("Password Activation Pending", { - description: "Check your email for a password reset link to complete activation. You'll receive two emails: one from Supabase with the reset link, and one from ThrillWiki with instructions.", - duration: Infinity, - action: { - label: "Resend Email", - onClick: async () => { - const result = await triggerOrphanedPasswordConfirmation('signin_toast'); - - if (result.success) { - sonnerToast.success("Reset Email Sent!", { - description: `Check ${result.email} for the password reset link from Supabase.`, - duration: 10000, - }); - } else { - sonnerToast.error("Failed to Send Email", { - description: result.error, - duration: 8000, - }); - } - } - }, - cancel: { - label: "Dismiss", - onClick: () => { - setOrphanedPasswordDismissed(); - authLog('[Auth] User dismissed orphaned password warning'); - } - } - }); - } - } - } catch (error) { - authError('[Auth] Failed to check for orphaned password:', error); - } - } }, 0); // Detect confirmed email change: email changed AND no longer pending diff --git a/src/lib/identityService.ts b/src/lib/identityService.ts index a11dddd2..34afaac5 100644 --- a/src/lib/identityService.ts +++ b/src/lib/identityService.ts @@ -160,49 +160,13 @@ export async function connectIdentity( } } -/** - * Wait for email provider to be created after password addition - * Supabase takes time to create the email identity, so we poll with retries - */ -async function waitForEmailProvider(maxRetries = 6): Promise { - const delays = [500, 1000, 1500, 2000, 2500, 3000]; // ~10.5s total - - for (let i = 0; i < maxRetries; i++) { - const identities = await getUserIdentities(); - const hasEmail = identities.some(id => id.provider === 'email'); - - if (hasEmail) { - console.log(`[IdentityService] Email provider found after ${i + 1} attempts`); - return true; - } - - console.log(`[IdentityService] Email provider not found, attempt ${i + 1}/${maxRetries}`); - - if (i < maxRetries - 1) { - await new Promise(resolve => setTimeout(resolve, delays[i])); - } - } - - console.error('[IdentityService] Email provider not found after max retries'); - return false; -} /** * Add password authentication to an OAuth-only account - * Automatically creates email identity by signing in immediately after setting password + * Triggers Supabase password reset flow - user sets password via email link */ -export async function addPasswordToAccount( - password: string -): Promise { +export async function addPasswordToAccount(): Promise { try { - // Validate password strength - if (password.length < 8) { - return { - success: false, - error: 'Password must be at least 8 characters long' - }; - } - const { data: { user } } = await supabase.auth.getUser(); const userEmail = user?.email; @@ -213,61 +177,30 @@ export async function addPasswordToAccount( }; } - console.log('[IdentityService] Initiating password setup via reset flow'); + console.log('[IdentityService] Sending password reset email'); - // Step 1: Store the desired password temporarily in session storage - // This will be used after clicking the reset link - sessionStorage.setItem('pending_password_setup', password); - console.log('[IdentityService] Stored pending password in session storage'); - - // Step 2: Trigger Supabase password reset email - // This creates the email identity when user clicks link and sets password + // Trigger Supabase password reset email + // User clicks link and sets password, which automatically creates email identity const { error: resetError } = await supabase.auth.resetPasswordForEmail( userEmail, { - redirectTo: `${window.location.origin}/auth/callback?action=password-setup` + redirectTo: `${window.location.origin}/auth/callback?type=recovery` } ); if (resetError) { console.error('[IdentityService] Failed to send password reset email:', resetError); - sessionStorage.removeItem('pending_password_setup'); // Cleanup on failure throw resetError; } console.log('[IdentityService] Password reset email sent successfully'); - // Step 3: Get user profile for custom notification email - const { data: profile } = await supabase - .from('profiles') - .select('display_name, username') - .eq('user_id', user!.id) - .single(); - - // Step 4: Send custom "Password Setup Instructions" email (informational) - console.log('[IdentityService] Sending custom notification email'); - try { - await supabase.functions.invoke('send-password-added-email', { - body: { - email: userEmail, - displayName: profile?.display_name, - username: profile?.username, - }, - }); - console.log('[IdentityService] Custom notification email sent'); - } catch (emailError) { - console.error('[IdentityService] Custom email failed (non-blocking):', emailError); - // Don't fail the whole operation - } - - // Step 5: Log the action + // Log the action await logIdentityChange(user!.id, 'password_setup_initiated', { method: 'reset_password_flow', - reset_email_sent: true, timestamp: new Date().toISOString() }); - // Return success - user needs to check email and click reset link return { success: true, needsEmailConfirmation: true, @@ -275,98 +208,6 @@ export async function addPasswordToAccount( }; } catch (error: any) { console.error('[IdentityService] Failed to initiate password setup:', error); - // Cleanup on error - sessionStorage.removeItem('pending_password_setup'); - return { - success: false, - error: error.message || 'Failed to initiate password setup' - }; - } -} - -/** - * Check if user has an orphaned password (password exists but no email identity) - */ -export async function hasOrphanedPassword(): Promise { - const identities = await getUserIdentities(); - const hasEmailIdentity = identities.some(i => i.provider === 'email'); - - if (hasEmailIdentity) return false; - - // If user has OAuth identities but no email identity, they might have an orphaned password - return identities.length > 0 && !hasEmailIdentity; -} - -/** - * Trigger email confirmation for orphaned password - * Direct trigger without requiring password re-entry - */ -export async function triggerOrphanedPasswordConfirmation( - source?: string -): Promise { - try { - const { data: { user } } = await supabase.auth.getUser(); - - if (!user?.email) { - return { - success: false, - error: 'No email found for current user' - }; - } - - console.log('[IdentityService] Resending password reset email for orphaned password'); - - // Send Supabase password reset email - // The user's password is already set in auth.users, they just need to click - // the reset link to create the email identity - const { error: resetError } = await supabase.auth.resetPasswordForEmail( - user.email, - { - redirectTo: `${window.location.origin}/auth/callback?action=confirm-password` - } - ); - - if (resetError) { - console.error('[IdentityService] Failed to send password reset email:', resetError); - throw resetError; - } - - console.log('[IdentityService] Password reset email sent successfully'); - - // Optional: Get profile for custom notification - const { data: profile } = await supabase - .from('profiles') - .select('display_name, username') - .eq('user_id', user.id) - .single(); - - // Optional: Send custom notification email (non-blocking) - try { - await supabase.functions.invoke('send-password-added-email', { - body: { - email: user.email, - displayName: profile?.display_name, - username: profile?.username, - }, - }); - } catch (emailError) { - console.error('[IdentityService] Custom email failed (non-blocking):', emailError); - } - - // Log the action - await logIdentityChange(user.id, 'orphaned_password_confirmation_triggered', { - method: source || 'manual_button_click', - timestamp: new Date().toISOString(), - reset_email_sent: true - }); - - return { - success: true, - needsEmailConfirmation: true, - email: user.email - }; - } catch (error: any) { - console.error('[IdentityService] Failed to trigger password reset:', error); return { success: false, error: error.message || 'Failed to send password reset email' @@ -374,6 +215,7 @@ export async function triggerOrphanedPasswordConfirmation( } } + /** * Log identity changes to audit log */ diff --git a/src/lib/sessionFlags.ts b/src/lib/sessionFlags.ts index 633761ff..24f9b0a6 100644 --- a/src/lib/sessionFlags.ts +++ b/src/lib/sessionFlags.ts @@ -7,7 +7,6 @@ export const SessionFlags = { MFA_INTENDED_PATH: 'mfa_intended_path', MFA_CHALLENGE_ID: 'mfa_challenge_id', AUTH_METHOD: 'auth_method', - ORPHANED_PASSWORD_DISMISSED: 'orphaned_password_dismissed', } as const; export type SessionFlagKey = typeof SessionFlags[keyof typeof SessionFlags]; @@ -74,27 +73,6 @@ export function clearAuthMethod(): void { sessionStorage.removeItem(SessionFlags.AUTH_METHOD); } -/** - * Set the orphaned password dismissed flag - */ -export function setOrphanedPasswordDismissed(): void { - sessionStorage.setItem(SessionFlags.ORPHANED_PASSWORD_DISMISSED, 'true'); -} - -/** - * Check if orphaned password warning has been dismissed this session - */ -export function isOrphanedPasswordDismissed(): boolean { - return sessionStorage.getItem(SessionFlags.ORPHANED_PASSWORD_DISMISSED) === 'true'; -} - -/** - * Clear the orphaned password dismissed flag - */ -export function clearOrphanedPasswordDismissed(): void { - sessionStorage.removeItem(SessionFlags.ORPHANED_PASSWORD_DISMISSED); -} - /** * Clear all authentication-related session flags */ diff --git a/src/pages/AuthCallback.tsx b/src/pages/AuthCallback.tsx index d85332c5..4723475e 100644 --- a/src/pages/AuthCallback.tsx +++ b/src/pages/AuthCallback.tsx @@ -49,116 +49,6 @@ export default function AuthCallback() { const user = session.user; console.log('[AuthCallback] User authenticated:', user.id); - // Check for password setup actions from reset flow - const urlParams = new URLSearchParams(window.location.search); - const action = urlParams.get('action'); - - if (action === 'password-setup-direct') { - console.log('[AuthCallback] Processing password-setup-direct action (direct reset flow)'); - - // User set password via Supabase's hosted page - // Email identity is already created automatically - - toast({ - title: "Password Set Successfully!", - description: "Your email identity has been created. You can now sign in with your email and password.", - }); - - // Redirect to auth page for sign-in - setTimeout(() => { - navigate('/auth'); - }, 1500); - - return; - } - - if (action === 'password-setup') { - console.log('[AuthCallback] Processing password-setup action'); - - // Retrieve the password from session storage - const pendingPassword = sessionStorage.getItem('pending_password_setup'); - - if (pendingPassword) { - try { - console.log('[AuthCallback] Setting password from pending setup'); - - // Set the password - this creates the email identity - const { error: passwordError } = await supabase.auth.updateUser({ - password: pendingPassword - }); - - if (passwordError) { - console.error('[AuthCallback] Failed to set password:', passwordError); - throw passwordError; - } - - // Clear session storage - sessionStorage.removeItem('pending_password_setup'); - console.log('[AuthCallback] Password set successfully, email identity created'); - - // Show success message - toast({ - title: "Password Set Successfully!", - description: "You can now sign in with your email and password.", - }); - - // Redirect to auth page for sign-in - setTimeout(() => { - navigate('/auth'); - }, 1500); - - return; - } catch (error: any) { - console.error('[AuthCallback] Password setup error:', error); - sessionStorage.removeItem('pending_password_setup'); // Cleanup - - toast({ - variant: 'destructive', - title: 'Password Setup Failed', - description: error.message || 'Failed to set password. Please try again.', - }); - - setTimeout(() => { - navigate('/settings?tab=security'); - }, 2000); - - return; - } - } else { - console.warn('[AuthCallback] No pending password found in session storage'); - toast({ - variant: 'destructive', - title: 'Password Setup Incomplete', - description: 'Please try setting your password again from Security Settings.', - }); - - setTimeout(() => { - navigate('/settings?tab=security'); - }, 2000); - - return; - } - } - - if (action === 'confirm-password') { - console.log('[AuthCallback] Processing confirm-password action (orphaned password)'); - - // For orphaned password, the password is already set in auth.users - // The reset link just needed to be clicked to create the email identity - // Supabase handles this automatically when the reset link is clicked - - toast({ - title: "Password Activated!", - description: "Your password authentication is now fully active. You can sign in with email and password.", - }); - - // Redirect to auth page for sign-in - setTimeout(() => { - navigate('/auth'); - }, 1500); - - return; - } // Check if this is a new OAuth user (created within last minute) const createdAt = new Date(user.created_at);