mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 10:51:12 -05:00
Refactor identity management
This commit is contained in:
@@ -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<OAuthProvider | null>(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,7 +193,6 @@ export function SecurityTab() {
|
||||
Change Password
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={handleAddPassword} disabled={addingPassword}>
|
||||
{addingPassword ? (
|
||||
<>
|
||||
@@ -258,28 +203,6 @@ export function SecurityTab() {
|
||||
'Add Password'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{showOrphanedPasswordOption && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSendConfirmationEmail}
|
||||
disabled={addingPassword}
|
||||
className="w-full"
|
||||
>
|
||||
{addingPassword ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Sending Email...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="w-4 h-4 mr-2" />
|
||||
Send Confirmation Email
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<IdentityOperationResult> {
|
||||
export async function addPasswordToAccount(): Promise<IdentityOperationResult> {
|
||||
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<boolean> {
|
||||
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<IdentityOperationResult> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user