From 076ee4e37a34a5378ed78397f05d477dd8240a05 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Wed, 4 Mar 2026 20:10:07 +0100 Subject: [PATCH 1/6] fix: improve timeline input , ui improvement and fixes for participation tab --- .../hackathons/new/tabs/ParticipantTab.tsx | 3 ++- .../new/tabs/components/timeline/DateTimeInput.tsx | 8 ++++---- .../new/tabs/schemas/participantSchema.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/components/organization/hackathons/new/tabs/ParticipantTab.tsx b/components/organization/hackathons/new/tabs/ParticipantTab.tsx index 730d227ce..5a149547a 100644 --- a/components/organization/hackathons/new/tabs/ParticipantTab.tsx +++ b/components/organization/hackathons/new/tabs/ParticipantTab.tsx @@ -300,7 +300,8 @@ export default function ParticipantTab({ /> {/* Team Size Settings */} - {participantType === 'team' && ( + {(participantType === 'team' || + participantType === 'team_or_individual') && (

Team Size

diff --git a/components/organization/hackathons/new/tabs/components/timeline/DateTimeInput.tsx b/components/organization/hackathons/new/tabs/components/timeline/DateTimeInput.tsx index 9f592e5f0..5dbe3b707 100644 --- a/components/organization/hackathons/new/tabs/components/timeline/DateTimeInput.tsx +++ b/components/organization/hackathons/new/tabs/components/timeline/DateTimeInput.tsx @@ -25,9 +25,9 @@ interface DateTimeInputProps { const formatTimeValue = (date?: Date): string => { if (!date) return ''; - const hours = `${date.getHours()}`.padStart(2, '0'); - const minutes = `${date.getMinutes()}`.padStart(2, '0'); - const seconds = `${date.getSeconds()}`.padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); return `${hours}:${minutes}:${seconds}`; }; @@ -100,7 +100,7 @@ export default function DateTimeInput({ { const timeValue = event.target.value; diff --git a/components/organization/hackathons/new/tabs/schemas/participantSchema.ts b/components/organization/hackathons/new/tabs/schemas/participantSchema.ts index a3df10165..5b6c558f6 100644 --- a/components/organization/hackathons/new/tabs/schemas/participantSchema.ts +++ b/components/organization/hackathons/new/tabs/schemas/participantSchema.ts @@ -52,6 +52,19 @@ export const participantSchema = z }); } } + + // New validation: At least one submission requirement must be selected + if ( + !data.require_github && + !data.require_demo_video && + !data.require_other_links + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least one submission requirement must be selected', + path: ['require_github'], + }); + } }); export type ParticipantFormData = z.input; From f66087510d7978bc1adf727623bc67085edf60e8 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Thu, 5 Mar 2026 06:29:18 +0100 Subject: [PATCH 2/6] fix: implement 2fa for email and password login --- app/me/layout.tsx | 12 +- app/me/settings/SettingsContent.tsx | 23 +- components/auth/LoginWrapper.tsx | 113 +++-- components/auth/TwoFactorVerify.tsx | 196 +++++++++ components/profile/update/SecurityTab.tsx | 166 +++++++ components/profile/update/TwoFactorTab.tsx | 480 +++++++++++++++++++++ lib/api/auth.ts | 109 +++++ 7 files changed, 1051 insertions(+), 48 deletions(-) create mode 100644 components/auth/TwoFactorVerify.tsx create mode 100644 components/profile/update/SecurityTab.tsx create mode 100644 components/profile/update/TwoFactorTab.tsx 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 2f0ad7017..ee21702c5 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -8,14 +8,19 @@ 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 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'; 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 { @@ -25,11 +30,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]); @@ -38,7 +47,8 @@ const SettingsContent = () => { invalidateAuthProfileCache(); }, [fetchUserData]); - if (isLoading) { + // Only show skeleton on first load — not on background refetches + if (isLoading && !hasLoadedOnce.current) { return (
@@ -117,6 +127,15 @@ const SettingsContent = () => { + + + + + + { 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,20 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(true); setLoadingState(true); }, - onSuccess: async () => { - await new Promise(resolve => setTimeout(resolve, 200)); - - 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, - }); - } + onSuccess: async ctx => { + const resData = ctx?.data as any; + 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 +242,19 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { } ); - // Handle error from return value if (error) { handleAuthError(error, values); setIsLoading(false); setLoadingState(false); + } else if ( + (data as any)?.twoFactorRequired || + (data as any)?.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 +268,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'); + setIsLoading(false); + 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'); + 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'); + setIsLoading(false); + setBackupCode(''); + return; + } + + if (data) { + toast.success('Recovery successful'); + await onSuccess(); + } + } catch (err) { + toast.error('An unexpected error occurred during verification'); + 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 + > + + + + + + + + + +
+ + +
+ ) : ( +
+
+
+ setBackupCode(e.target.value)} + className='h-14 w-full rounded-lg border border-zinc-800 bg-zinc-900/50 text-center text-xl tracking-widest text-white focus:border-[#a7f950]/50 focus:outline-none' + autoFocus + /> +
+ +
+ + +
+ )} + +
+ +
+
+
+ ); +}; + +export default TwoFactorVerify; diff --git a/components/profile/update/SecurityTab.tsx b/components/profile/update/SecurityTab.tsx new file mode 100644 index 000000000..fbe98f3f4 --- /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, KeyIcon, 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 +

+
+
+ +
+
+ + + {errors.currentPassword && ( +

+ {errors.currentPassword.message} +

+ )} +
+ +
+ + + {errors.newPassword && ( +

+ {errors.newPassword.message} +

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + + Update 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/TwoFactorTab.tsx b/components/profile/update/TwoFactorTab.tsx new file mode 100644 index 000000000..1ed44c775 --- /dev/null +++ b/components/profile/update/TwoFactorTab.tsx @@ -0,0 +1,480 @@ +'use client'; + +import { useState, useEffect } from 'react'; +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 { QRCodeSVG } from 'qrcode.react'; +import { User } from '@/types/user'; +import { + Loader2, + Copy, + ShieldCheck, + ShieldAlert, + KeyRound, + Smartphone, +} from 'lucide-react'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; + +interface TwoFactorTabProps { + user: User; + onStatusChange: () => void; +} + +const TwoFactorTab = ({ user, onStatusChange }: TwoFactorTabProps) => { + const [step, setStep] = useState<'status' | 'setup' | 'verify' | 'backup'>( + 'status' + ); + const [password, setPassword] = useState(''); + const [totpUri, setTotpUri] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [secretKey, setSecretKey] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showCodes, setShowCodes] = useState(false); + + const handleStartSetup = async () => { + if (!password) { + toast.error('Please enter your password to enable 2FA'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.enable({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to start 2FA setup'); + } else if (data) { + setTotpUri(data.totpURI); + // Extract secret key from URI (format: otpauth://totp/...secret=KEY&...) + const secret = data.totpURI.split('secret=')[1]?.split('&')[0]; + setSecretKey(secret || ''); + setBackupCodes(data.backupCodes); + setStep('setup'); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleVerifySetup = async (codeValue: string) => { + if (codeValue.length !== 6) return; + + setIsLoading(true); + try { + const { error } = await authClient.twoFactor.verifyTotp({ + code: codeValue, + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setVerificationCode(''); // Clear on error + } else { + toast.success('Two-factor authentication enabled successfully!'); + setStep('backup'); + onStatusChange(); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleRegenerateCodes = async () => { + if (!password) { + toast.error('Please enter your password to regenerate codes'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.generateBackupCodes({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to regenerate backup codes'); + } else if (data) { + setBackupCodes(data.backupCodes); + setShowCodes(true); + setPassword(''); + toast.success('New backup codes generated'); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleDisable = async () => { + if (!password) { + toast.error('Please enter your password to disable 2FA'); + return; + } + + setIsLoading(true); + try { + const { error } = await authClient.twoFactor.disable({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to disable 2FA'); + } else { + toast.success('Two-factor authentication disabled'); + setPassword(''); + setStep('status'); + onStatusChange(); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const copyBackupCodes = (codes: string[]) => { + navigator.clipboard.writeText(codes.join('\n')); + toast.success('Backup codes copied to clipboard'); + }; + + if (user.twoFactorEnabled && step === 'status') { + return ( +
+
+
+ +
+
+

2FA is Enabled

+

+ Your account is protected with an extra layer of security. You + will be prompted for a verification code when signing in. +

+
+
+ +
+
+
+ +

Backup Codes

+
+

+ Backup codes can be used to access your account if you lose your + authentication device. Each code can only be used once. +

+ + {!showCodes ? ( +
+
+ + setPassword(e.target.value)} + className='h-11 border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600' + /> +
+
+ + Regenerate Codes + + + Disable 2FA + +
+
+ ) : ( +
+
+ {backupCodes.map((code, i) => ( +
+ {code} +
+ ))} +
+
+ copyBackupCodes(backupCodes)} + > + Copy All + + setShowCodes(false)} + > + Hide Codes + +
+
+ )} +
+
+
+ ); + } + + if (step === 'status') { + return ( +
+
+
+ +
+
+

+ 2FA is Not Enabled +

+

+ We recommend enabling two-factor authentication to keep your + account secure. You'll need an authenticator app like Google + Authenticator or Authy. +

+
+
+ +
+
+ + setPassword(e.target.value)} + className='h-11 border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600' + /> +
+ + Setup 2FA + +
+
+ ); + } + + if (step === 'setup') { + return ( +
+
+
+ 1 +
+

Scan QR Code

+
+ +
+
+ +
+
+
+ +

+ Scan this code with your authenticator app. +

+
+
+ +

+ If you can't scan, you can manually enter the secret key. +

+
+ + {secretKey && ( +
+
+ + Secret Key + + + {secretKey} + +
+ +
+ )} + +
+ setStep('verify')} + > + Next: Verify Code + +
+
+
+
+ ); + } + + if (step === 'verify') { + return ( +
+
+
+ 2 +
+

Verify Setup

+
+ +
+
+

+ Enter the 6-digit code from your app to confirm everything is + working. +

+
+
+ { + setVerificationCode(val); + if (val.length === 6) { + handleVerifySetup(val); + } + }} + disabled={isLoading} + autoFocus + > + + + + + + + + + +
+
+ setStep('setup')} + disabled={isLoading} + > + Back + +
+
+
+ ); + } + + if (step === 'backup') { + return ( +
+
+
+ +

2FA Enabled Successfully

+
+

+ Please save these backup codes in a safe place. You can use them to + access your account if you lose your phone. +

+
+ +
+
+ {backupCodes.map((code, i) => ( +
+ {code} +
+ ))} +
+
+ copyBackupCodes(backupCodes)} + > + Copy Codes + + { + setStep('status'); + setPassword(''); + setShowCodes(false); + }} + > + Done + +
+
+
+ ); + } + + return null; +}; + +export default TwoFactorTab; diff --git a/lib/api/auth.ts b/lib/api/auth.ts index 4f0252d9c..be6e9e071 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -298,3 +298,112 @@ export const updateUserAvatar = async ( message: axiosRes.data.message, }; }; +/** + * Two-factor authentication interfaces + */ +export interface TwoFactorStatusResponse { + twoFactorEnabled: boolean; +} + +export interface GetTotpUriResponse { + totpURI: string; +} + +export interface VerifyTotpResponse { + status: boolean; +} + +export interface EnableTwoFactorResponse { + totpURI: string; + backupCodes: string[]; +} + +export interface GenerateBackupCodesResponse { + status: boolean; + backupCodes: string[]; +} + +/** + * Two-factor authentication API methods + */ +export const getTotpUri = async (password: string): Promise => { + const res = await api.post( + '/auth/two-factor/get-totp-uri', + { password } + ); + return res.data.totpURI; +}; + +export const verifyTotp = async ( + code: string, + trustDevice: boolean | null = null +): Promise => { + const res = await api.post( + '/auth/two-factor/verify-totp', + { code, trustDevice } + ); + return res.data.status; +}; + +export const sendTwoFactorOtp = async (): Promise => { + const res = await api.post<{ status: boolean }>('/auth/two-factor/send-otp'); + return res.data.status; +}; + +export const verifyTwoFactorOtp = async ( + code: string, + trustDevice: boolean | null = null +): Promise<{ token: string; user: User }> => { + const res = await api.post<{ token: string; user: User }>( + '/auth/two-factor/verify-otp', + { code, trustDevice } + ); + return res.data; +}; + +export const verifyBackupCode = async ( + code: string, + trustDevice: boolean | null = null, + disableSession: boolean | null = null +): Promise<{ user: User; session: any }> => { + const res = await api.post<{ user: User; session: any }>( + '/auth/two-factor/verify-backup-code', + { + code, + trustDevice, + disableSession, + } + ); + return res.data; +}; + +export const generateBackupCodes = async ( + password: string +): Promise => { + const res = await api.post( + '/auth/two-factor/generate-backup-codes', + { password } + ); + return res.data.backupCodes; +}; + +export const enableTwoFactor = async ( + password: string, + issuer: string | null = 'Boundless' +): Promise => { + const res = await api.post( + '/auth/two-factor/enable', + { + password, + issuer: issuer || 'Boundless', + } + ); + return res.data; +}; + +export const disableTwoFactor = async (password: string): Promise => { + const res = await api.post<{ status: boolean }>('/auth/two-factor/disable', { + password, + }); + return res.data.status; +}; From 5460d202edb06e141e79947b63a3e87cc30a86f9 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Thu, 5 Mar 2026 07:04:33 +0100 Subject: [PATCH 3/6] fix: fix conflict --- app/me/settings/SettingsContent.tsx | 45 +- components/auth/LoginWrapper.tsx | 14 +- components/auth/TwoFactorVerify.tsx | 36 +- components/profile/update/SecurityTab.tsx | 2 +- components/profile/update/Settings.tsx | 1033 ++++++++++---------- components/profile/update/TwoFactorTab.tsx | 48 +- lib/api/auth.ts | 36 +- 7 files changed, 654 insertions(+), 560 deletions(-) diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index ee21702c5..0f24738de 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -13,6 +13,7 @@ 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(); @@ -122,19 +123,51 @@ const SettingsContent = () => { - + {userData?.user ? ( + + ) : ( +
+ + Loading profile... +
+ )}
+ + + + + + + + + - + {userData?.user ? ( + + ) : ( +
+ + + Loading security settings... + +
+ )}
- + {userData?.user ? ( + + ) : ( +
+ + Loading 2FA settings... +
+ )}
{ setLoadingState(true); }, onSuccess: async ctx => { - const resData = ctx?.data as any; + const resData = ctx?.data as + | { + twoFactorRequired?: boolean; + twoFactorRedirect?: boolean; + } + | undefined; + if (resData?.twoFactorRequired || resData?.twoFactorRedirect) { setTwoFactorRequired(true); setIsLoading(false); @@ -247,8 +253,10 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(false); setLoadingState(false); } else if ( - (data as any)?.twoFactorRequired || - (data as any)?.twoFactorRedirect + (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean }) + ?.twoFactorRequired || + (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean }) + ?.twoFactorRedirect ) { setTwoFactorRequired(true); setIsLoading(false); diff --git a/components/auth/TwoFactorVerify.tsx b/components/auth/TwoFactorVerify.tsx index 7b0931d79..4ef56f64f 100644 --- a/components/auth/TwoFactorVerify.tsx +++ b/components/auth/TwoFactorVerify.tsx @@ -32,7 +32,6 @@ const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { if (error) { toast.error(error.message || 'Verification failed'); - setIsLoading(false); setCode(''); // Clear on error to let user try again return; } @@ -43,6 +42,7 @@ const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { } } catch (err) { toast.error('An unexpected error occurred during verification'); + } finally { setIsLoading(false); } }; @@ -62,17 +62,18 @@ const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { if (error) { toast.error(error.message || 'Verification failed'); - setIsLoading(false); setBackupCode(''); return; } if (data) { toast.success('Recovery successful'); + setBackupCode(''); await onSuccess(); } } catch (err) { toast.error('An unexpected error occurred during verification'); + } finally { setIsLoading(false); } }; @@ -112,30 +113,13 @@ const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { autoFocus > - - - - - - + {[...Array(6)].map((_, i) => ( + + ))}
diff --git a/components/profile/update/SecurityTab.tsx b/components/profile/update/SecurityTab.tsx index fbe98f3f4..4bdb58785 100644 --- a/components/profile/update/SecurityTab.tsx +++ b/components/profile/update/SecurityTab.tsx @@ -9,7 +9,7 @@ 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, KeyIcon, LockIcon } from 'lucide-react'; +import { ShieldCheck, LockIcon } from 'lucide-react'; import { User } from '@/types/user'; const passwordSchema = z diff --git a/components/profile/update/Settings.tsx b/components/profile/update/Settings.tsx index 4ebbc63c7..44167714f 100644 --- a/components/profile/update/Settings.tsx +++ b/components/profile/update/Settings.tsx @@ -63,7 +63,16 @@ const settingsSchema = z.object({ type SettingsFormData = z.infer; -const Settings = () => { +interface SettingsProps { + visibleSections?: ( + | 'notifications' + | 'privacy' + | 'appearance' + | 'preferences' + )[]; +} + +const Settings = ({ visibleSections }: SettingsProps) => { const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [settings, setSettings] = useState({ @@ -192,547 +201,419 @@ const Settings = () => { return (
- {/* Header */} -
-

Settings

-

- Manage your account preferences and privacy settings -

-
+ {/* Header - Only show if no specific sections are requested (Main Settings page) */} + {!visibleSections && ( +
+

Settings

+

+ Manage your account preferences and privacy settings +

+
+ )}
{/* Notifications */} - -
- -

- Notifications -

-
+ {(!visibleSections || visibleSections.includes('notifications')) && ( + +
+ +

+ Notifications +

+
-
- ( - -
- - Email Notifications - - - Receive email notifications about account activity - -
- - { - field.onChange(checked); - await onSubmitNotifications({ - emailNotifications: checked, - pushNotifications: form.getValues( - 'notifications.pushNotifications' - ), - }); - }} - /> - -
- )} - /> +
+ ( + +
+ + Email Notifications + + + Receive email notifications about account activity + +
+ + { + field.onChange(checked); + await onSubmitNotifications({ + emailNotifications: checked, + pushNotifications: form.getValues( + 'notifications.pushNotifications' + ), + }); + }} + /> + +
+ )} + /> - ( - -
- - Push Notifications - - - Receive push notifications in your browser - -
- - { - field.onChange(checked); - await onSubmitNotifications({ - pushNotifications: checked, - emailNotifications: form.getValues( - 'notifications.emailNotifications' - ), - }); - }} - /> - -
- )} - /> -
- + ( + +
+ + Push Notifications + + + Receive push notifications in your browser + +
+ + { + field.onChange(checked); + await onSubmitNotifications({ + pushNotifications: checked, + emailNotifications: form.getValues( + 'notifications.emailNotifications' + ), + }); + }} + /> + +
+ )} + /> +
+
+ )} {/* Privacy */} - -
- -

Privacy

-
+ {(!visibleSections || visibleSections.includes('privacy')) && ( + +
+ +

Privacy

+
-
- ( - -
- - Public Profile - - - Make your profile visible to other users - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: checked, - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> - - ( - -
- - Email Visibility - - - Show your email address on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: checked, - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> - - ( - -
- - Location Visibility - - - Show your location on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: checked, - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> - - ( - -
- - Company Visibility - - - Show your company on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: checked, - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> - - ( - -
- - Website Visibility - - - Show your website on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: checked, - socialLinksVisibility: form.getValues( - 'privacy.socialLinksVisibility' - ), - }); - }} - /> - -
- )} - /> +
+ ( + +
+ + Public Profile + + + Make your profile visible to other users + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: checked, + emailVisibility: form.getValues( + 'privacy.emailVisibility' + ), + locationVisibility: form.getValues( + 'privacy.locationVisibility' + ), + companyVisibility: form.getValues( + 'privacy.companyVisibility' + ), + websiteVisibility: form.getValues( + 'privacy.websiteVisibility' + ), + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> - ( - -
- - Social Links Visibility - - - Show your social links on your profile - -
- - { - field.onChange(checked); - await onSubmitPrivacy({ - publicProfile: form.getValues( - 'privacy.publicProfile' - ), - emailVisibility: form.getValues( - 'privacy.emailVisibility' - ), - locationVisibility: form.getValues( - 'privacy.locationVisibility' - ), - companyVisibility: form.getValues( - 'privacy.companyVisibility' - ), - websiteVisibility: form.getValues( - 'privacy.websiteVisibility' - ), - socialLinksVisibility: checked, - }); - }} - /> - -
- )} - /> -
- + ( + +
+ + Email Visibility + + + Show your email address on your profile + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: form.getValues( + 'privacy.publicProfile' + ), + emailVisibility: checked, + locationVisibility: form.getValues( + 'privacy.locationVisibility' + ), + companyVisibility: form.getValues( + 'privacy.companyVisibility' + ), + websiteVisibility: form.getValues( + 'privacy.websiteVisibility' + ), + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> - {/* Appearance */} - -
- -

Appearance

-
+ ( + +
+ + Location Visibility + + + Show your location on your profile + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: form.getValues( + 'privacy.publicProfile' + ), + emailVisibility: form.getValues( + 'privacy.emailVisibility' + ), + locationVisibility: checked, + companyVisibility: form.getValues( + 'privacy.companyVisibility' + ), + websiteVisibility: form.getValues( + 'privacy.websiteVisibility' + ), + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> - ( - - Theme - - - - )} - /> -
+ ( + +
+ + Company Visibility + + + Show your company on your profile + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: form.getValues( + 'privacy.publicProfile' + ), + emailVisibility: form.getValues( + 'privacy.emailVisibility' + ), + locationVisibility: form.getValues( + 'privacy.locationVisibility' + ), + companyVisibility: checked, + websiteVisibility: form.getValues( + 'privacy.websiteVisibility' + ), + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> - {/* Preferences */} - -
- -

Preferences

-
+ ( + +
+ + Website Visibility + + + Show your website on your profile + +
+ + { + field.onChange(checked); + await onSubmitPrivacy({ + publicProfile: form.getValues( + 'privacy.publicProfile' + ), + emailVisibility: form.getValues( + 'privacy.emailVisibility' + ), + locationVisibility: form.getValues( + 'privacy.locationVisibility' + ), + companyVisibility: form.getValues( + 'privacy.companyVisibility' + ), + websiteVisibility: checked, + socialLinksVisibility: form.getValues( + 'privacy.socialLinksVisibility' + ), + }); + }} + /> + +
+ )} + /> -
- {/* Language */} - ( - - Language - - - - )} - /> + + )} + /> +
+
+ )} + + {/* Appearance */} + {(!visibleSections || visibleSections.includes('appearance')) && ( + +
+ +

Appearance

+
- {/* Timezone */} ( - Timezone + Theme @@ -740,11 +621,151 @@ const Settings = () => { )} /> +
+ )} + + {/* Preferences */} + {(!visibleSections || visibleSections.includes('preferences')) && ( + +
+ +

+ Preferences +

+
+ +
+ {/* Language */} + ( + + Language + + + + )} + /> + + {/* Timezone */} + ( + + Timezone + + + + )} + /> - {/* Categories and Skills would be implemented here if needed */} - {/* They are arrays in the API but for now we'll keep them as empty arrays */} -
-
+ {/* Categories and Skills would be implemented here if needed */} + {/* They are arrays in the API but for now we'll keep them as empty arrays */} +
+
+ )} {/* Save Button */}
diff --git a/components/profile/update/TwoFactorTab.tsx b/components/profile/update/TwoFactorTab.tsx index 1ed44c775..37c3bc1aa 100644 --- a/components/profile/update/TwoFactorTab.tsx +++ b/components/profile/update/TwoFactorTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { toast } from 'sonner'; import { authClient } from '@/lib/auth-client'; import { BoundlessButton } from '@/components/buttons'; @@ -55,9 +55,15 @@ const TwoFactorTab = ({ user, onStatusChange }: TwoFactorTabProps) => { toast.error(error.message || 'Failed to start 2FA setup'); } else if (data) { setTotpUri(data.totpURI); - // Extract secret key from URI (format: otpauth://totp/...secret=KEY&...) - const secret = data.totpURI.split('secret=')[1]?.split('&')[0]; - setSecretKey(secret || ''); + try { + const url = new URL(data.totpURI); + const secret = url.searchParams.get('secret'); + setSecretKey(secret || ''); + } catch (e) { + // Fallback to simple split if URL parsing fails + const secret = data.totpURI.split('secret=')[1]?.split('&')[0]; + setSecretKey(secret || ''); + } setBackupCodes(data.backupCodes); setStep('setup'); } @@ -146,9 +152,37 @@ const TwoFactorTab = ({ user, onStatusChange }: TwoFactorTabProps) => { } }; - const copyBackupCodes = (codes: string[]) => { - navigator.clipboard.writeText(codes.join('\n')); - toast.success('Backup codes copied to clipboard'); + const copyBackupCodes = async (codes: string[]) => { + const text = codes.join('\n'); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + toast.success('Backup codes copied to clipboard'); + } else { + throw new Error('Clipboard API unavailable'); + } + } catch (err) { + // Fallback for older browsers or non-secure contexts + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '0'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + if (successful) { + toast.success('Backup codes copied to clipboard'); + } else { + throw new Error('Fallback copy failed'); + } + } catch (fallbackErr) { + toast.error('Unable to copy backup codes'); + } + } }; if (user.twoFactorEnabled && step === 'status') { diff --git a/lib/api/auth.ts b/lib/api/auth.ts index be6e9e071..dd04a0827 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -324,9 +324,17 @@ export interface GenerateBackupCodesResponse { } /** - * Two-factor authentication API methods + * AXIOS-BASED 2FA HELPERS + * + * Note: These functions use direct axios-based API calls instead of the Better Auth client plugin. + * They are preserved for use in internal tools, CLI scripts, or specific out-of-UI contexts + * where the standard authClient plugins are not appropriate. + * + * For standard UI components, prefer using `authClient.twoFactor.*`. */ -export const getTotpUri = async (password: string): Promise => { + +/** + * Get TOTP URI for setup const res = await api.post( '/auth/two-factor/get-totp-uri', { password } @@ -361,19 +369,25 @@ export const verifyTwoFactorOtp = async ( return res.data; }; +/** + * Verify backup code + */ export const verifyBackupCode = async ( code: string, trustDevice: boolean | null = null, disableSession: boolean | null = null -): Promise<{ user: User; session: any }> => { - const res = await api.post<{ user: User; session: any }>( - '/auth/two-factor/verify-backup-code', - { - code, - trustDevice, - disableSession, - } - ); +): Promise<{ + user: User; + session: { id: string; userId: string; token: string; expiresAt: Date }; +}> => { + const res = await api.post<{ + user: User; + session: { id: string; userId: string; token: string; expiresAt: Date }; + }>('/auth/two-factor/verify-backup-code', { + code, + trustDevice, + disableSession, + }); return res.data; }; From a6599eb8619bd9147011403240ce984ddd2c4889 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Fri, 6 Mar 2026 17:05:35 +0100 Subject: [PATCH 4/6] fix: fix submission form --- .../hackathons/submissions/SubmissionForm.tsx | 20 ++++++++++--------- .../hackathons/submissions/submissionTab.tsx | 3 ++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx index abb2e0e4f..3c48a5347 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -1081,15 +1081,17 @@ const SubmissionFormContent: React.FC = ({
- + {process.env.NODE_ENV === 'development' && ( + + )}
= ({ {!isLoadingMySubmission && !mySubmission && isAuthenticated && - isRegistered && ( + isRegistered && + status !== 'upcoming' && (

You haven't submitted a project yet. From 222cd4582510814554c6ccccc0e45f0944cd69e1 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Sat, 7 Mar 2026 14:44:29 +0100 Subject: [PATCH 5/6] fix: fix hackathon submission and participant page --- .../hackathons/[slug]/HackathonPageClient.tsx | 2 +- .../hackathons/[slug]/submit/page.tsx | 120 +++++++++ .../hackathons/submissions/SubmissionForm.tsx | 43 +++- .../hackathons/submissions/submissionCard.tsx | 62 ++--- .../hackathons/submissions/submissionTab.tsx | 38 +-- .../settings/GeneralSettingsTab.tsx | 43 +++- components/stepper/Stepper.tsx | 40 ++- hooks/hackathon/use-participants.ts | 230 ++++++++++++------ 8 files changed, 420 insertions(+), 158 deletions(-) create mode 100644 app/(landing)/hackathons/[slug]/submit/page.tsx diff --git a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx index 0c4d1adf3..5ca55a13e 100644 --- a/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx +++ b/app/(landing)/hackathons/[slug]/HackathonPageClient.tsx @@ -296,7 +296,7 @@ export default function HackathonPageClient() { }; const handleSubmitClick = () => { - router.push('?tab=submission'); + router.push(`/hackathons/${currentHackathon?.slug}/submit`); }; const handleViewSubmissionClick = () => { diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx new file mode 100644 index 000000000..f5ca054e2 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/submit/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useHackathonData } from '@/lib/providers/hackathonProvider'; +import { useAuthStatus } from '@/hooks/use-auth'; +import { useSubmission } from '@/hooks/hackathon/use-submission'; +import { SubmissionFormContent } from '@/components/hackathons/submissions/SubmissionForm'; +import LoadingScreen from '@/features/projects/components/CreateProjectModal/LoadingScreen'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import { toast } from 'sonner'; + +export default function SubmitProjectPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const router = useRouter(); + const { isAuthenticated, isLoading } = useAuthStatus(); + + const resolvedParams = use(params); + const hackathonSlug = resolvedParams.slug; + + const { + currentHackathon, + loading: hackathonLoading, + setCurrentHackathon, + } = useHackathonData(); + + useEffect(() => { + if (hackathonSlug) { + setCurrentHackathon(hackathonSlug); + } + }, [hackathonSlug, setCurrentHackathon]); + + const hackathonId = currentHackathon?.id || ''; + const orgId = currentHackathon?.organizationId || undefined; + + const { + submission: mySubmission, + isFetching: isLoadingMySubmission, + fetchMySubmission, + } = useSubmission({ + hackathonSlugOrId: hackathonId || '', + autoFetch: isAuthenticated && !!hackathonId, + }); + + // Authentication check + useEffect(() => { + if (!isLoading && !isAuthenticated) { + toast.error('You must be logged in to submit a project'); + router.push( + `/auth?mode=signin&callbackUrl=/hackathons/${hackathonSlug}/submit` + ); + } + }, [isAuthenticated, isLoading, router, hackathonSlug]); + + const handleClose = () => { + router.push(`/hackathons/${hackathonSlug}`); + }; + + const handleSuccess = () => { + fetchMySubmission(); + toast.success( + mySubmission + ? 'Submission updated successfully!' + : 'Project submitted successfully!' + ); + router.push(`/hackathons/${hackathonSlug}?tab=submission`); + }; + + if ( + isLoading || + hackathonLoading || + isLoadingMySubmission || + !currentHackathon + ) { + return ; + } + + return ( +

+
+ + +
+ +
+
+
+ ); +} diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx index 3c48a5347..1604189b6 100644 --- a/components/hackathons/submissions/SubmissionForm.tsx +++ b/components/hackathons/submissions/SubmissionForm.tsx @@ -125,6 +125,7 @@ interface SubmissionFormContentProps { initialData?: Partial; submissionId?: string; onSuccess?: () => void; + onClose?: () => void; } const INITIAL_STEPS: Step[] = [ @@ -198,8 +199,19 @@ const SubmissionFormContent: React.FC = ({ initialData, submissionId, onSuccess, + onClose, }) => { - const { collapse, isExpanded: open } = useExpandableScreen(); + // Use context carefully since it might not be available when used standalone + let collapse = () => {}; + let open = true; + try { + const expandableCtx = useExpandableScreen(); + collapse = expandableCtx.collapse; + open = expandableCtx.isExpanded; + } catch (e) { + // Standalone mode, not in ExpandableScreen + } + const { currentHackathon } = useHackathonData(); const { user } = useAuthStatus(); @@ -773,7 +785,11 @@ const SubmissionFormContent: React.FC = ({ } else { await create(submissionData); } - collapse(); + if (onClose) { + onClose(); + } else { + collapse(); + } onSuccess?.(); } catch { // Error handled in hook @@ -1503,22 +1519,31 @@ const SubmissionFormContent: React.FC = ({ -
+
-
+
{renderStepContent()}
{currentStep < steps.length - 1 ? ( - - - - - Edit Submission - - e.stopPropagation()}> + + + + + - - Delete Submission - - - + onEditClick?.()} + className='cursor-pointer text-gray-300 focus:bg-gray-800 focus:text-white' + > + + Edit Submission + + onDeleteClick?.()} + className='cursor-pointer text-red-500 focus:bg-red-900/20 focus:text-red-400' + > + + Delete Submission + + + +
)}
diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index 61f17bec7..3492fb774 100644 --- a/components/hackathons/submissions/submissionTab.tsx +++ b/components/hackathons/submissions/submissionTab.tsx @@ -54,6 +54,7 @@ interface SubmissionTabContentProps extends SubmissionTabProps { fetchMySubmission: () => Promise; removeSubmission: (id: string) => Promise; hackathonId: string; + hackathonSlug: string; } const SubmissionTabContent: React.FC = ({ @@ -64,10 +65,10 @@ const SubmissionTabContent: React.FC = ({ fetchMySubmission, removeSubmission, hackathonId, + hackathonSlug, }) => { const { isAuthenticated } = useAuthStatus(); const router = useRouter(); - const { expand } = useExpandableScreen(); const [viewMode, setViewMode] = useState('grid'); @@ -129,6 +130,7 @@ const SubmissionTabContent: React.FC = ({ await removeSubmission(submissionToDelete); setSubmissionToDelete(null); toast.success('Submission deleted successfully'); + window.location.reload(); } catch (error) { reportError(error, { context: 'submission-delete', @@ -274,7 +276,7 @@ const SubmissionTabContent: React.FC = ({ You haven't submitted a project yet.

)} diff --git a/components/hackathons/hackathonStickyCard.tsx b/components/hackathons/hackathonStickyCard.tsx index 470562408..9a4a9c2a0 100644 --- a/components/hackathons/hackathonStickyCard.tsx +++ b/components/hackathons/hackathonStickyCard.tsx @@ -238,20 +238,17 @@ export function HackathonStickyCard(props: HackathonStickyCardProps) { )} - {/* View Submission Button */} - {status === 'ongoing' && - isRegistered && - hasSubmitted && - onViewSubmissionClick && ( - - )} + {/* Edit / View Submission Button */} + {status === 'ongoing' && isRegistered && hasSubmitted && ( + + )} {/* Find Team Button */} {status === 'ongoing' && diff --git a/components/hackathons/submissions/submissionTab.tsx b/components/hackathons/submissions/submissionTab.tsx index 3492fb774..fff6a4974 100644 --- a/components/hackathons/submissions/submissionTab.tsx +++ b/components/hackathons/submissions/submissionTab.tsx @@ -83,7 +83,8 @@ const SubmissionTabContent: React.FC = ({ setSelectedSort, setSelectedCategory, } = useSubmissions(); - const { currentHackathon } = useHackathonData(); + const { currentHackathon, loading: isHackathonDataLoading } = + useHackathonData(); const { status } = useHackathonStatus( currentHackathon?.startDate, currentHackathon?.submissionDeadline @@ -265,6 +266,14 @@ const SubmissionTabContent: React.FC = ({
+ {/* Loading State */} + {(isLoadingMySubmission || isHackathonDataLoading) && ( +
+ + Loading submissions... +
+ )} + {/* Submissions Grid with Create Button if no submission */} {!isLoadingMySubmission && !mySubmission && @@ -289,7 +298,9 @@ const SubmissionTabContent: React.FC = ({ )} {/* Submissions Grid / List */} - {submissions.length > 0 || mySubmission ? ( + {!isLoadingMySubmission && + !isHackathonDataLoading && + (submissions.length > 0 || mySubmission) ? (
{ - if (currentHackathonSlug === slug && fetchingRef.current) return; - setCurrentHackathonSlug(slug); const data = await fetchHackathonBySlug(slug);