Refactor identity management

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 17:38:18 +00:00
parent 5c075c363e
commit a255442616
5 changed files with 21 additions and 456 deletions

View File

@@ -16,8 +16,7 @@ import {
checkDisconnectSafety, checkDisconnectSafety,
disconnectIdentity, disconnectIdentity,
connectIdentity, connectIdentity,
hasOrphanedPassword, addPasswordToAccount
triggerOrphanedPasswordConfirmation
} from '@/lib/identityService'; } from '@/lib/identityService';
import type { UserIdentity, OAuthProvider } from '@/types/identity'; import type { UserIdentity, OAuthProvider } from '@/types/identity';
import { toast as sonnerToast } from '@/components/ui/sonner'; import { toast as sonnerToast } from '@/components/ui/sonner';
@@ -33,26 +32,12 @@ export function SecurityTab() {
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null); const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
const [hasPassword, setHasPassword] = useState(false); const [hasPassword, setHasPassword] = useState(false);
const [addingPassword, setAddingPassword] = useState(false); const [addingPassword, setAddingPassword] = useState(false);
const [showOrphanedPasswordOption, setShowOrphanedPasswordOption] = useState(false);
// Load user identities on mount // Load user identities on mount
useEffect(() => { useEffect(() => {
loadIdentities(); loadIdentities();
}, []); }, []);
useEffect(() => {
const checkOrphanedPassword = async () => {
if (!hasPassword) {
const isOrphaned = await hasOrphanedPassword();
setShowOrphanedPasswordOption(isOrphaned);
}
};
if (!loadingIdentities) {
checkOrphanedPassword();
}
}, [hasPassword, loadingIdentities]);
const loadIdentities = async () => { const loadIdentities = async () => {
try { try {
setLoadingIdentities(true); setLoadingIdentities(true);
@@ -140,50 +125,11 @@ export function SecurityTab() {
const handleAddPassword = async () => { const handleAddPassword = async () => {
setAddingPassword(true); setAddingPassword(true);
const { data: { user } } = await supabase.auth.getUser(); const result = await addPasswordToAccount();
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');
if (result.success) { if (result.success) {
sonnerToast.success("Reset Email Sent!", { sonnerToast.success("Password Setup 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.", description: `Check ${result.email} for a password reset link. Click it to set your password.`,
duration: 15000, duration: 15000,
}); });
} else { } else {
@@ -247,7 +193,6 @@ export function SecurityTab() {
Change Password Change Password
</Button> </Button>
) : ( ) : (
<>
<Button onClick={handleAddPassword} disabled={addingPassword}> <Button onClick={handleAddPassword} disabled={addingPassword}>
{addingPassword ? ( {addingPassword ? (
<> <>
@@ -258,28 +203,6 @@ export function SecurityTab() {
'Add Password' 'Add Password'
)} )}
</Button> </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> </CardContent>
</Card> </Card>

View File

@@ -113,74 +113,6 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
} else { } else {
setAal(null); 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); }, 0);
// Detect confirmed email change: email changed AND no longer pending // Detect confirmed email change: email changed AND no longer pending

View File

@@ -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 * 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( export async function addPasswordToAccount(): Promise<IdentityOperationResult> {
password: string
): Promise<IdentityOperationResult> {
try { 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 { data: { user } } = await supabase.auth.getUser();
const userEmail = user?.email; 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 // Trigger Supabase password reset email
// This will be used after clicking the reset link // User clicks link and sets password, which automatically creates email identity
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
const { error: resetError } = await supabase.auth.resetPasswordForEmail( const { error: resetError } = await supabase.auth.resetPasswordForEmail(
userEmail, userEmail,
{ {
redirectTo: `${window.location.origin}/auth/callback?action=password-setup` redirectTo: `${window.location.origin}/auth/callback?type=recovery`
} }
); );
if (resetError) { if (resetError) {
console.error('[IdentityService] Failed to send password reset email:', resetError); console.error('[IdentityService] Failed to send password reset email:', resetError);
sessionStorage.removeItem('pending_password_setup'); // Cleanup on failure
throw resetError; throw resetError;
} }
console.log('[IdentityService] Password reset email sent successfully'); console.log('[IdentityService] Password reset email sent successfully');
// Step 3: Get user profile for custom notification email // Log the action
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
await logIdentityChange(user!.id, 'password_setup_initiated', { await logIdentityChange(user!.id, 'password_setup_initiated', {
method: 'reset_password_flow', method: 'reset_password_flow',
reset_email_sent: true,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
// Return success - user needs to check email and click reset link
return { return {
success: true, success: true,
needsEmailConfirmation: true, needsEmailConfirmation: true,
@@ -275,98 +208,6 @@ export async function addPasswordToAccount(
}; };
} catch (error: any) { } catch (error: any) {
console.error('[IdentityService] Failed to initiate password setup:', error); 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 { return {
success: false, success: false,
error: error.message || 'Failed to send password reset email' error: error.message || 'Failed to send password reset email'
@@ -374,6 +215,7 @@ export async function triggerOrphanedPasswordConfirmation(
} }
} }
/** /**
* Log identity changes to audit log * Log identity changes to audit log
*/ */

View File

@@ -7,7 +7,6 @@ export const SessionFlags = {
MFA_INTENDED_PATH: 'mfa_intended_path', MFA_INTENDED_PATH: 'mfa_intended_path',
MFA_CHALLENGE_ID: 'mfa_challenge_id', MFA_CHALLENGE_ID: 'mfa_challenge_id',
AUTH_METHOD: 'auth_method', AUTH_METHOD: 'auth_method',
ORPHANED_PASSWORD_DISMISSED: 'orphaned_password_dismissed',
} as const; } as const;
export type SessionFlagKey = typeof SessionFlags[keyof typeof SessionFlags]; export type SessionFlagKey = typeof SessionFlags[keyof typeof SessionFlags];
@@ -74,27 +73,6 @@ export function clearAuthMethod(): void {
sessionStorage.removeItem(SessionFlags.AUTH_METHOD); 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 * Clear all authentication-related session flags
*/ */

View File

@@ -49,116 +49,6 @@ export default function AuthCallback() {
const user = session.user; const user = session.user;
console.log('[AuthCallback] User authenticated:', user.id); 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) // Check if this is a new OAuth user (created within last minute)
const createdAt = new Date(user.created_at); const createdAt = new Date(user.created_at);