diff --git a/app/me/layout.tsx b/app/me/layout.tsx
index a2ad6e87f..0560658fa 100644
--- a/app/me/layout.tsx
+++ b/app/me/layout.tsx
@@ -4,7 +4,7 @@ import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { useAuthStatus } from '@/hooks/use-auth';
-import React, { useMemo } from 'react';
+import React, { useMemo, useRef, useEffect } from 'react';
import LoadingSpinner from '@/components/LoadingSpinner';
interface MeLayoutProps {
@@ -33,6 +33,13 @@ const getId = (item: ProfileItemWithId): string | undefined =>
const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => {
const { user, isLoading } = useAuthStatus();
+ // Track whether we've completed the very first load.
+ // This prevents children from unmounting during background session refetches
+ // (e.g. on window focus), which would destroy component state like 2FA steps.
+ const hasLoadedOnce = useRef(false);
+ useEffect(() => {
+ if (!isLoading) hasLoadedOnce.current = true;
+ }, [isLoading]);
const { name = '', email = '', profile, image: userImage = '' } = user || {};
const typedProfile = profile as MeLayoutProfile | null | undefined;
@@ -62,7 +69,8 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => {
}).length;
}, [typedProfile]);
- if (isLoading) {
+ // Only show full-screen spinner on first load, not on background refetches
+ if (isLoading && !hasLoadedOnce.current) {
return (
diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx
index 80ade6288..0f24738de 100644
--- a/app/me/settings/SettingsContent.tsx
+++ b/app/me/settings/SettingsContent.tsx
@@ -8,15 +8,20 @@ import { getMe } from '@/lib/api/auth';
import { GetMeResponse } from '@/lib/api/types';
import { Skeleton } from '@/components/ui/skeleton';
import Settings from '@/components/profile/update/Settings';
-import { SecuritySettingsTab } from '@/components/profile/update/SecuritySettingsTab';
+import TwoFactorTab from '@/components/profile/update/TwoFactorTab';
+import SecurityTab from '@/components/profile/update/SecurityTab';
import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection';
import { invalidateAuthProfileCache } from '@/hooks/use-auth';
+import { useRef } from 'react';
+import { Loader2 } from 'lucide-react';
const SettingsContent = () => {
const searchParams = useSearchParams();
const fromVerification = searchParams.get('verification') === 'complete';
const [userData, setUserData] = useState
(null);
const [isLoading, setIsLoading] = useState(true);
+ // Prevent unmounting tabs on background refetches (e.g. after 2FA enable)
+ const hasLoadedOnce = useRef(false);
const fetchUserData = useCallback(async () => {
try {
@@ -26,11 +31,15 @@ const SettingsContent = () => {
setUserData(null);
} finally {
setIsLoading(false);
+ hasLoadedOnce.current = true;
}
}, []);
useEffect(() => {
- setIsLoading(true);
+ // Only set isLoading true on the very first fetch
+ if (!hasLoadedOnce.current) {
+ setIsLoading(true);
+ }
fetchUserData();
}, [fetchUserData]);
@@ -39,7 +48,8 @@ const SettingsContent = () => {
invalidateAuthProfileCache();
}, [fetchUserData]);
- if (isLoading) {
+ // Only show skeleton on first load — not on background refetches
+ if (isLoading && !hasLoadedOnce.current) {
return (
@@ -113,7 +123,14 @@ const SettingsContent = () => {
-
+ {userData?.user ? (
+
+ ) : (
+
+
+ Loading profile...
+
+ )}
@@ -127,8 +144,30 @@ const SettingsContent = () => {
-
-
+
+ {userData?.user ? (
+
+ ) : (
+
+
+
+ Loading security settings...
+
+
+ )}
+
+
+ {userData?.user ? (
+
+ ) : (
+
+
+ Loading 2FA settings...
+
+ )}
{
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [lastMethod, setLastMethod] = useState(null);
+ const [twoFactorRequired, setTwoFactorRequired] = useState(false);
const callbackUrl = searchParams.get('callbackUrl')
? decodeURIComponent(searchParams.get('callbackUrl')!)
@@ -42,16 +44,6 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
setLastMethod(method);
}, []);
- useEffect(() => {
- const method = authClient.getLastUsedLoginMethod();
- setLastMethod(method);
- }, []);
-
- useEffect(() => {
- const method = authClient.getLastUsedLoginMethod();
- setLastMethod(method);
- }, []);
-
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -89,6 +81,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
const errorStatus = error.status;
const errorCode = error.code;
+ // Check for 2FA requirement
+ if (
+ errorStatus === 403 &&
+ (errorMessage === 'two_factor_required' ||
+ errorMessage?.includes('two_factor') ||
+ errorCode === 'TWO_FACTOR_REQUIRED')
+ ) {
+ setTwoFactorRequired(true);
+ return;
+ }
+
if (errorStatus === 403 || errorCode === 'FORBIDDEN') {
const message =
'Please verify your email before signing in. Check your inbox for a verification link.';
@@ -171,8 +174,36 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
setIsLoading(true);
setLoadingState(true);
+ const syncSession = async () => {
+ const session = await authClient.getSession();
+ if (session && typeof session === 'object' && 'user' in session) {
+ const sessionUser = session.user as
+ | {
+ id: string;
+ email: string;
+ name?: string | null;
+ image?: string | null;
+ }
+ | null
+ | undefined;
+
+ if (sessionUser && sessionUser.id && sessionUser.email) {
+ const authStore = useAuthStore.getState();
+ await authStore.syncWithSession({
+ id: sessionUser.id,
+ email: sessionUser.email,
+ name: sessionUser.name || undefined,
+ image: sessionUser.image || undefined,
+ role: 'USER',
+ username: undefined,
+ accessToken: undefined,
+ });
+ }
+ }
+ };
+
try {
- const { error } = await authClient.signIn.email(
+ const { data, error } = await authClient.signIn.email(
{
email: values.email,
password: values.password,
@@ -184,42 +215,26 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
setIsLoading(true);
setLoadingState(true);
},
- onSuccess: async () => {
- await new Promise(resolve => setTimeout(resolve, 200));
+ onSuccess: async ctx => {
+ const resData = ctx?.data as
+ | {
+ twoFactorRequired?: boolean;
+ twoFactorRedirect?: boolean;
+ }
+ | undefined;
- const session = await authClient.getSession();
-
- if (session && typeof session === 'object' && 'user' in session) {
- const sessionUser = session.user as
- | {
- id: string;
- email: string;
- name?: string | null;
- image?: string | null;
- }
- | null
- | undefined;
-
- if (sessionUser && sessionUser.id && sessionUser.email) {
- const authStore = useAuthStore.getState();
- await authStore.syncWithSession({
- id: sessionUser.id,
- email: sessionUser.email,
- name: sessionUser.name || undefined,
- image: sessionUser.image || undefined,
- role: 'USER',
- username: undefined,
- accessToken: undefined,
- });
- }
+ if (resData?.twoFactorRequired || resData?.twoFactorRedirect) {
+ setTwoFactorRequired(true);
+ setIsLoading(false);
+ setLoadingState(false);
+ return;
}
- // Keep loading state active during redirect
- // The page will unmount when redirecting, so no need to set false
+ await new Promise(resolve => setTimeout(resolve, 200));
+ await syncSession();
window.location.href = callbackUrl;
},
onError: ctx => {
- // Handle error from Better Auth callback
const errorObj = ctx.error || ctx;
handleAuthError(
typeof errorObj === 'object'
@@ -233,14 +248,21 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
}
);
- // Handle error from return value
if (error) {
handleAuthError(error, values);
setIsLoading(false);
setLoadingState(false);
+ } else if (
+ (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean })
+ ?.twoFactorRequired ||
+ (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean })
+ ?.twoFactorRedirect
+ ) {
+ setTwoFactorRequired(true);
+ setIsLoading(false);
+ setLoadingState(false);
}
} catch (error) {
- // Handle unexpected errors
const errorObj =
error instanceof Error
? { message: error.message, status: undefined, code: undefined }
@@ -254,6 +276,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
[handleAuthError, setLoadingState, callbackUrl]
);
+ if (twoFactorRequired) {
+ return (
+ {
+ window.location.href = callbackUrl;
+ }}
+ onCancel={() => setTwoFactorRequired(false)}
+ />
+ );
+ }
+
return (
Promise;
+ onCancel: () => void;
+}
+
+const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => {
+ const [code, setCode] = useState('');
+ const [backupCode, setBackupCode] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isBackupMode, setIsBackupMode] = useState(false);
+
+ const handleVerify = async (codeValue: string) => {
+ if (codeValue.length < 6) return;
+
+ setIsLoading(true);
+ try {
+ const { data, error } = await authClient.twoFactor.verifyTotp({
+ code: codeValue,
+ });
+
+ if (error) {
+ toast.error(error.message || 'Verification failed');
+ setCode(''); // Clear on error to let user try again
+ return;
+ }
+
+ if (data) {
+ toast.success('Verification successful');
+ await onSuccess();
+ }
+ } catch (err) {
+ toast.error('An unexpected error occurred during verification');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleVerifyBackupCode = async (e?: React.FormEvent) => {
+ e?.preventDefault();
+ if (!backupCode) {
+ toast.error('Please enter a backup code');
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ const { data, error } = await authClient.twoFactor.verifyBackupCode({
+ code: backupCode.trim(),
+ });
+
+ if (error) {
+ toast.error(error.message || 'Verification failed');
+ setBackupCode('');
+ return;
+ }
+
+ if (data) {
+ toast.success('Recovery successful');
+ setBackupCode('');
+ await onSuccess();
+ }
+ } catch (err) {
+ toast.error('An unexpected error occurred during verification');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {isBackupMode ? 'Account Recovery' : 'Two-Factor Authentication'}
+
+
+ {isBackupMode
+ ? 'Enter a one-time backup code to access your account.'
+ : 'Enter the 6-digit verification code from your authenticator app.'}
+
+
+
+
+ {!isBackupMode ? (
+
+
+ {
+ setCode(val);
+ if (val.length === 6) {
+ handleVerify(val);
+ }
+ }}
+ disabled={isLoading}
+ autoFocus
+ >
+
+ {[...Array(6)].map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default TwoFactorVerify;
diff --git a/components/profile/update/SecurityTab.tsx b/components/profile/update/SecurityTab.tsx
new file mode 100644
index 000000000..4bdb58785
--- /dev/null
+++ b/components/profile/update/SecurityTab.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import { toast } from 'sonner';
+import { authClient } from '@/lib/auth-client';
+import { BoundlessButton } from '@/components/buttons';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { ShieldCheck, LockIcon } from 'lucide-react';
+import { User } from '@/types/user';
+
+const passwordSchema = z
+ .object({
+ currentPassword: z.string().min(1, 'Current password is required'),
+ newPassword: z
+ .string()
+ .min(8, 'New password must be at least 8 characters'),
+ confirmPassword: z.string().min(1, 'Please confirm your new password'),
+ })
+ .refine(data => data.newPassword === data.confirmPassword, {
+ message: "Passwords don't match",
+ path: ['confirmPassword'],
+ });
+
+type PasswordFormValues = z.infer;
+
+interface SecurityTabProps {
+ user: User;
+}
+
+const SecurityTab = ({ user }: SecurityTabProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(passwordSchema),
+ });
+
+ const onSubmit = async (data: PasswordFormValues) => {
+ setIsLoading(true);
+ try {
+ const { error } = await authClient.changePassword({
+ currentPassword: data.currentPassword,
+ newPassword: data.newPassword,
+ });
+
+ if (error) {
+ toast.error(error.message || 'Failed to update password');
+ } else {
+ toast.success('Password updated successfully');
+ reset();
+ }
+ } catch (err) {
+ toast.error('An unexpected error occurred');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Change Password
+
+
+ Update your account password
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Two-Factor Authentication
+
+
+ Add an extra layer of security to your account. You can manage
+ this in the dedicated 2FA tab.
+
+
+
+
+
+
+ {user.twoFactorEnabled ? 'Enabled' : 'Disabled'}
+
+
+
+
+
+ );
+};
+
+export default SecurityTab;
diff --git a/components/profile/update/Settings.tsx b/components/profile/update/Settings.tsx
index 010c08947..e1c4ec0c6 100644
--- a/components/profile/update/Settings.tsx
+++ b/components/profile/update/Settings.tsx
@@ -63,25 +63,16 @@ const settingsSchema = z.object({
type SettingsFormData = z.infer;
-export type SettingsSection =
- | 'notifications'
- | 'privacy'
- | 'appearance'
- | 'preferences';
-
interface SettingsProps {
- /** When set, only these sections are rendered (for use in separate tabs). */
- visibleSections?: SettingsSection[];
+ visibleSections?: (
+ | 'notifications'
+ | 'privacy'
+ | 'appearance'
+ | 'preferences'
+ )[];
}
const Settings = ({ visibleSections }: SettingsProps) => {
- const sections: SettingsSection[] = visibleSections ?? [
- 'notifications',
- 'privacy',
- 'appearance',
- 'preferences',
- ];
- const showAllSections = !visibleSections || visibleSections.length === 0;
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [settings, setSettings] = useState({
@@ -251,7 +242,8 @@ const Settings = ({ visibleSections }: SettingsProps) => {