Skip to content

Commit fb0c8c5

Browse files
authored
UI fixes (#444)
* fix: improve timeline input , ui improvement and fixes for participation tab * fix: implement 2fa for email and password login * fix: fix conflict
1 parent 12f6ef0 commit fb0c8c5

8 files changed

Lines changed: 1124 additions & 68 deletions

File tree

app/me/layout.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AppSidebar } from '@/components/app-sidebar';
44
import { SiteHeader } from '@/components/site-header';
55
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
66
import { useAuthStatus } from '@/hooks/use-auth';
7-
import React, { useMemo } from 'react';
7+
import React, { useMemo, useRef, useEffect } from 'react';
88
import LoadingSpinner from '@/components/LoadingSpinner';
99

1010
interface MeLayoutProps {
@@ -33,6 +33,13 @@ const getId = (item: ProfileItemWithId): string | undefined =>
3333

3434
const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => {
3535
const { user, isLoading } = useAuthStatus();
36+
// Track whether we've completed the very first load.
37+
// This prevents children from unmounting during background session refetches
38+
// (e.g. on window focus), which would destroy component state like 2FA steps.
39+
const hasLoadedOnce = useRef(false);
40+
useEffect(() => {
41+
if (!isLoading) hasLoadedOnce.current = true;
42+
}, [isLoading]);
3643

3744
const { name = '', email = '', profile, image: userImage = '' } = user || {};
3845
const typedProfile = profile as MeLayoutProfile | null | undefined;
@@ -62,7 +69,8 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => {
6269
}).length;
6370
}, [typedProfile]);
6471

65-
if (isLoading) {
72+
// Only show full-screen spinner on first load, not on background refetches
73+
if (isLoading && !hasLoadedOnce.current) {
6674
return (
6775
<div className='flex h-screen items-center justify-center'>
6876
<LoadingSpinner size='xl' color='white' />

app/me/settings/SettingsContent.tsx

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,20 @@ import { getMe } from '@/lib/api/auth';
88
import { GetMeResponse } from '@/lib/api/types';
99
import { Skeleton } from '@/components/ui/skeleton';
1010
import Settings from '@/components/profile/update/Settings';
11-
import { SecuritySettingsTab } from '@/components/profile/update/SecuritySettingsTab';
11+
import TwoFactorTab from '@/components/profile/update/TwoFactorTab';
12+
import SecurityTab from '@/components/profile/update/SecurityTab';
1213
import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection';
1314
import { invalidateAuthProfileCache } from '@/hooks/use-auth';
15+
import { useRef } from 'react';
16+
import { Loader2 } from 'lucide-react';
1417

1518
const SettingsContent = () => {
1619
const searchParams = useSearchParams();
1720
const fromVerification = searchParams.get('verification') === 'complete';
1821
const [userData, setUserData] = useState<GetMeResponse | null>(null);
1922
const [isLoading, setIsLoading] = useState(true);
23+
// Prevent unmounting tabs on background refetches (e.g. after 2FA enable)
24+
const hasLoadedOnce = useRef(false);
2025

2126
const fetchUserData = useCallback(async () => {
2227
try {
@@ -26,11 +31,15 @@ const SettingsContent = () => {
2631
setUserData(null);
2732
} finally {
2833
setIsLoading(false);
34+
hasLoadedOnce.current = true;
2935
}
3036
}, []);
3137

3238
useEffect(() => {
33-
setIsLoading(true);
39+
// Only set isLoading true on the very first fetch
40+
if (!hasLoadedOnce.current) {
41+
setIsLoading(true);
42+
}
3443
fetchUserData();
3544
}, [fetchUserData]);
3645

@@ -39,7 +48,8 @@ const SettingsContent = () => {
3948
invalidateAuthProfileCache();
4049
}, [fetchUserData]);
4150

42-
if (isLoading) {
51+
// Only show skeleton on first load — not on background refetches
52+
if (isLoading && !hasLoadedOnce.current) {
4353
return (
4454
<div>
4555
<Skeleton className='h-full w-full' />
@@ -113,7 +123,14 @@ const SettingsContent = () => {
113123
</TabsTrigger>
114124
</TabsList>
115125
<TabsContent value='profile'>
116-
<Profile user={userData?.user as User} />
126+
{userData?.user ? (
127+
<Profile user={userData.user as User} />
128+
) : (
129+
<div className='flex items-center justify-center p-12'>
130+
<Loader2 className='mr-2 h-8 w-8 animate-spin text-[#a7f950]' />
131+
<span className='text-zinc-500'>Loading profile...</span>
132+
</div>
133+
)}
117134
</TabsContent>
118135
<TabsContent value='settings'>
119136
<Settings />
@@ -127,8 +144,30 @@ const SettingsContent = () => {
127144
<TabsContent value='preferences' className='space-y-6'>
128145
<Settings visibleSections={['appearance', 'preferences']} />
129146
</TabsContent>
130-
<TabsContent value='security' className='space-y-6'>
131-
<SecuritySettingsTab />
147+
<TabsContent value='security'>
148+
{userData?.user ? (
149+
<SecurityTab user={userData.user as User} />
150+
) : (
151+
<div className='flex items-center justify-center p-12'>
152+
<Loader2 className='mr-2 h-8 w-8 animate-spin text-[#a7f950]' />
153+
<span className='text-zinc-500'>
154+
Loading security settings...
155+
</span>
156+
</div>
157+
)}
158+
</TabsContent>
159+
<TabsContent value='2fa'>
160+
{userData?.user ? (
161+
<TwoFactorTab
162+
user={userData.user as User}
163+
onStatusChange={fetchUserData}
164+
/>
165+
) : (
166+
<div className='flex items-center justify-center p-12'>
167+
<Loader2 className='mr-2 h-8 w-8 animate-spin text-[#a7f950]' />
168+
<span className='text-zinc-500'>Loading 2FA settings...</span>
169+
</div>
170+
)}
132171
</TabsContent>
133172
<TabsContent value='identity' className='space-y-6'>
134173
<IdentityVerificationSection

components/auth/LoginWrapper.tsx

Lines changed: 76 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useForm } from 'react-hook-form';
77
import { toast } from 'sonner';
88
import z from 'zod';
99
import LoginForm from './LoginForm';
10+
import TwoFactorVerify from './TwoFactorVerify';
1011
import { authClient } from '@/lib/auth-client';
1112
import { useAuthStore } from '@/lib/stores/auth-store';
1213

@@ -32,6 +33,7 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
3233
const [showPassword, setShowPassword] = useState(false);
3334
const [isLoading, setIsLoading] = useState(false);
3435
const [lastMethod, setLastMethod] = useState<string | null>(null);
36+
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
3537

3638
const callbackUrl = searchParams.get('callbackUrl')
3739
? decodeURIComponent(searchParams.get('callbackUrl')!)
@@ -42,16 +44,6 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
4244
setLastMethod(method);
4345
}, []);
4446

45-
useEffect(() => {
46-
const method = authClient.getLastUsedLoginMethod();
47-
setLastMethod(method);
48-
}, []);
49-
50-
useEffect(() => {
51-
const method = authClient.getLastUsedLoginMethod();
52-
setLastMethod(method);
53-
}, []);
54-
5547
const form = useForm<FormData>({
5648
resolver: zodResolver(formSchema),
5749
defaultValues: {
@@ -89,6 +81,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
8981
const errorStatus = error.status;
9082
const errorCode = error.code;
9183

84+
// Check for 2FA requirement
85+
if (
86+
errorStatus === 403 &&
87+
(errorMessage === 'two_factor_required' ||
88+
errorMessage?.includes('two_factor') ||
89+
errorCode === 'TWO_FACTOR_REQUIRED')
90+
) {
91+
setTwoFactorRequired(true);
92+
return;
93+
}
94+
9295
if (errorStatus === 403 || errorCode === 'FORBIDDEN') {
9396
const message =
9497
'Please verify your email before signing in. Check your inbox for a verification link.';
@@ -171,8 +174,36 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
171174
setIsLoading(true);
172175
setLoadingState(true);
173176

177+
const syncSession = async () => {
178+
const session = await authClient.getSession();
179+
if (session && typeof session === 'object' && 'user' in session) {
180+
const sessionUser = session.user as
181+
| {
182+
id: string;
183+
email: string;
184+
name?: string | null;
185+
image?: string | null;
186+
}
187+
| null
188+
| undefined;
189+
190+
if (sessionUser && sessionUser.id && sessionUser.email) {
191+
const authStore = useAuthStore.getState();
192+
await authStore.syncWithSession({
193+
id: sessionUser.id,
194+
email: sessionUser.email,
195+
name: sessionUser.name || undefined,
196+
image: sessionUser.image || undefined,
197+
role: 'USER',
198+
username: undefined,
199+
accessToken: undefined,
200+
});
201+
}
202+
}
203+
};
204+
174205
try {
175-
const { error } = await authClient.signIn.email(
206+
const { data, error } = await authClient.signIn.email(
176207
{
177208
email: values.email,
178209
password: values.password,
@@ -184,42 +215,26 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
184215
setIsLoading(true);
185216
setLoadingState(true);
186217
},
187-
onSuccess: async () => {
188-
await new Promise(resolve => setTimeout(resolve, 200));
218+
onSuccess: async ctx => {
219+
const resData = ctx?.data as
220+
| {
221+
twoFactorRequired?: boolean;
222+
twoFactorRedirect?: boolean;
223+
}
224+
| undefined;
189225

190-
const session = await authClient.getSession();
191-
192-
if (session && typeof session === 'object' && 'user' in session) {
193-
const sessionUser = session.user as
194-
| {
195-
id: string;
196-
email: string;
197-
name?: string | null;
198-
image?: string | null;
199-
}
200-
| null
201-
| undefined;
202-
203-
if (sessionUser && sessionUser.id && sessionUser.email) {
204-
const authStore = useAuthStore.getState();
205-
await authStore.syncWithSession({
206-
id: sessionUser.id,
207-
email: sessionUser.email,
208-
name: sessionUser.name || undefined,
209-
image: sessionUser.image || undefined,
210-
role: 'USER',
211-
username: undefined,
212-
accessToken: undefined,
213-
});
214-
}
226+
if (resData?.twoFactorRequired || resData?.twoFactorRedirect) {
227+
setTwoFactorRequired(true);
228+
setIsLoading(false);
229+
setLoadingState(false);
230+
return;
215231
}
216232

217-
// Keep loading state active during redirect
218-
// The page will unmount when redirecting, so no need to set false
233+
await new Promise(resolve => setTimeout(resolve, 200));
234+
await syncSession();
219235
window.location.href = callbackUrl;
220236
},
221237
onError: ctx => {
222-
// Handle error from Better Auth callback
223238
const errorObj = ctx.error || ctx;
224239
handleAuthError(
225240
typeof errorObj === 'object'
@@ -233,14 +248,21 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
233248
}
234249
);
235250

236-
// Handle error from return value
237251
if (error) {
238252
handleAuthError(error, values);
239253
setIsLoading(false);
240254
setLoadingState(false);
255+
} else if (
256+
(data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean })
257+
?.twoFactorRequired ||
258+
(data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean })
259+
?.twoFactorRedirect
260+
) {
261+
setTwoFactorRequired(true);
262+
setIsLoading(false);
263+
setLoadingState(false);
241264
}
242265
} catch (error) {
243-
// Handle unexpected errors
244266
const errorObj =
245267
error instanceof Error
246268
? { message: error.message, status: undefined, code: undefined }
@@ -254,6 +276,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => {
254276
[handleAuthError, setLoadingState, callbackUrl]
255277
);
256278

279+
if (twoFactorRequired) {
280+
return (
281+
<TwoFactorVerify
282+
onSuccess={async () => {
283+
window.location.href = callbackUrl;
284+
}}
285+
onCancel={() => setTwoFactorRequired(false)}
286+
/>
287+
);
288+
}
289+
257290
return (
258291
<LoginForm
259292
form={form}

0 commit comments

Comments
 (0)