From fa30849d038265e949c98b7746f2b8e44019731a Mon Sep 17 00:00:00 2001 From: Maulana Sodiqin Date: Fri, 28 Nov 2025 17:40:34 +0700 Subject: [PATCH] feat(hackathon): improve auth flow and UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add email activation requirement for signup (no auto-login) - Add form validation with React Hook Form and Zod on signup page - Update callback page to handle email confirmation and password reset redirects - Fix token format in API interceptor (use access_token) - Fix middleware to use SessionToken for auth check - Add infinite scroll with IntersectionObserver on browse teams page - Replace all internal with components - Add useInfiniteTeams hook for paginated team browsing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/hackathon/src/app/auth/callback/page.tsx | 29 ++- apps/hackathon/src/app/auth/login/page.tsx | 6 +- apps/hackathon/src/app/auth/signup/page.tsx | 201 +++++++++++++----- apps/hackathon/src/app/maintenance/page.tsx | 8 +- apps/hackathon/src/app/page.tsx | 8 +- apps/hackathon/src/app/teams/browse/page.tsx | 199 ++++++++++------- apps/hackathon/src/middleware.ts | 23 +- libs/service/src/api/hackathon.ts | 4 +- libs/service/src/hooks/auth/index.ts | 31 +-- libs/service/src/hooks/teams/index.ts | 35 ++- 10 files changed, 363 insertions(+), 181 deletions(-) diff --git a/apps/hackathon/src/app/auth/callback/page.tsx b/apps/hackathon/src/app/auth/callback/page.tsx index 1346cc8..7122242 100644 --- a/apps/hackathon/src/app/auth/callback/page.tsx +++ b/apps/hackathon/src/app/auth/callback/page.tsx @@ -18,15 +18,38 @@ const CallbackPage: FC = (): ReactElement => { hasRunRef.current = true; try { - // Get the code from URL query params + // Check URL hash for Supabase email confirmation callback + const hashParams = new URLSearchParams(globalThis.location.hash.substring(1)); const urlParams = new URLSearchParams(globalThis.location.search); + + const type = hashParams.get('type') || urlParams.get('type'); + const accessToken = hashParams.get('access_token') || urlParams.get('access_token'); + + // Handle email confirmation callback from Supabase + if (type === 'signup' || type === 'email_confirmation' || type === 'recovery') { + // Don't auto sign in - redirect to login with success message + setIsProcessing(false); + + if (type === 'recovery') { + // Password reset - redirect to reset password page + toast.success('Email verified! Please set your new password.'); + navigate('/auth/reset-password' + (accessToken ? `?access_token=${accessToken}` : '')); + } else { + // Email confirmation for signup + toast.success('Email verified successfully! Please log in to continue.'); + navigate('/auth/login'); + } + return; + } + + // Get the code from URL query params (GitHub OAuth) const code = urlParams.get('code'); if (!code) { - throw new Error('No authorization code received from GitHub'); + throw new Error('No authorization code received'); } - // Exchange the code for tokens using backend API + // Exchange the code for tokens using backend API (GitHub OAuth) const result = await exchangeGitHubCode({ code }); toast.success('Login successful!'); diff --git a/apps/hackathon/src/app/auth/login/page.tsx b/apps/hackathon/src/app/auth/login/page.tsx index 59a872c..081d5e2 100644 --- a/apps/hackathon/src/app/auth/login/page.tsx +++ b/apps/hackathon/src/app/auth/login/page.tsx @@ -172,12 +172,12 @@ export default function LoginPage() {

Don't have an account?{' '} - Sign up - +

diff --git a/apps/hackathon/src/app/auth/signup/page.tsx b/apps/hackathon/src/app/auth/signup/page.tsx index 7b25ae5..98545ea 100644 --- a/apps/hackathon/src/app/auth/signup/page.tsx +++ b/apps/hackathon/src/app/auth/signup/page.tsx @@ -1,55 +1,125 @@ import { useState } from 'react'; -import { - useGitHubAuth, - useSignup, -} from '@imphnen-frontend-service/service'; +import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service'; import { GithubOutlined } from '@ant-design/icons'; -import { useNavigate } from 'react-router'; +import { useNavigate, Link, Links } from 'react-router'; import { toast } from 'sonner'; import { Icon } from '@iconify/react'; import { ThemeToggle } from '../../../components/theme-toggle'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; + +const signupSchema = z + .object({ + fullname: z + .string() + .min(1, 'Full name is required') + .min(2, 'Full name must be at least 2 characters'), + email: z + .string() + .min(1, 'Email is required') + .email('Please enter a valid email address'), + password: z + .string() + .min(1, 'Password is required') + .min(6, 'Password must be at least 6 characters'), + confirmPassword: z.string().min(1, 'Please confirm your password'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); + +type SignupFormData = z.infer; export default function SignupPage() { const navigate = useNavigate(); const { signInWithGitHub } = useGitHubAuth(); const signupMutation = useSignup(); const [isGithubLoading, setIsGithubLoading] = useState(false); - const [fullname, setFullname] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); + const [registrationSuccess, setRegistrationSuccess] = useState(false); + const [registeredEmail, setRegisteredEmail] = useState(''); - const handleEmailSignup = async (e: React.FormEvent) => { - e.preventDefault(); + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SignupFormData) => { setError(null); - if (!fullname || !email || !password || !confirmPassword) { - setError('Please fill in all fields'); - return; - } - - if (password !== confirmPassword) { - setError('Passwords do not match'); - return; - } - - if (password.length < 6) { - setError('Password must be at least 6 characters long'); - return; - } - try { - await signupMutation.mutateAsync({ email, password, fullname }); - - toast.success('Account created successfully!'); - navigate('/onboarding/user'); + const result = await signupMutation.mutateAsync({ + email: data.email, + password: data.password, + fullname: data.fullname, + }); + toast.success(result.message); + setRegisteredEmail(data.email); + setRegistrationSuccess(true); } catch (err) { console.error('[Signup] Email signup failed:', err); setError((err as Error).message || 'Signup failed'); } }; + // Show success screen after registration + if (registrationSuccess) { + return ( +
+
+
+
+ +
+

+ Check Your Email +

+

+ We've sent an activation link to{' '} + {registeredEmail} +

+
+ +
+

+ Click the link in the email to activate your account. The link + will expire in 24 hours. +

+ +
+

+ Don't forget to check your spam folder if you don't see the + email. +

+
+ + + + + + +
+
+
+ ); + } + const handleGithubLogin = async () => { try { setIsGithubLoading(true); @@ -69,6 +139,11 @@ export default function SignupPage() { } }; + const inputBaseClass = + 'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed'; + const inputErrorClass = 'border-red-500 dark:border-red-500'; + const inputNormalClass = 'border-gray-300 dark:border-gray-600'; + return (
@@ -98,7 +173,7 @@ export default function SignupPage() {
)} -
+
@@ -128,13 +208,18 @@ export default function SignupPage() { setEmail(e.target.value)} + {...register('email')} placeholder="your@email.com" disabled={signupMutation.isPending} - className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" - required + className={`${inputBaseClass} ${ + errors.email ? inputErrorClass : inputNormalClass + }`} /> + {errors.email && ( +

+ {errors.email.message} +

+ )}
@@ -147,13 +232,18 @@ export default function SignupPage() { setPassword(e.target.value)} + {...register('password')} placeholder="••••••••" disabled={signupMutation.isPending} - className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" - required + className={`${inputBaseClass} ${ + errors.password ? inputErrorClass : inputNormalClass + }`} /> + {errors.password && ( +

+ {errors.password.message} +

+ )}
@@ -166,21 +256,28 @@ export default function SignupPage() { setConfirmPassword(e.target.value)} + {...register('confirmPassword')} placeholder="••••••••" disabled={signupMutation.isPending} - className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" - required + className={`${inputBaseClass} ${ + errors.confirmPassword ? inputErrorClass : inputNormalClass + }`} /> + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )}
@@ -207,12 +304,12 @@ export default function SignupPage() {

Already have an account?{' '} - Sign in - +

diff --git a/apps/hackathon/src/app/maintenance/page.tsx b/apps/hackathon/src/app/maintenance/page.tsx index 8ab57f5..5d2beda 100644 --- a/apps/hackathon/src/app/maintenance/page.tsx +++ b/apps/hackathon/src/app/maintenance/page.tsx @@ -1,3 +1,5 @@ +import { Link } from 'react-router'; + export default function MaintenancePage() { return (
@@ -9,12 +11,12 @@ export default function MaintenancePage() {
Thank you for your patience.

- Back to Homepage - +
); diff --git a/apps/hackathon/src/app/page.tsx b/apps/hackathon/src/app/page.tsx index 69b4ac5..6b35a26 100644 --- a/apps/hackathon/src/app/page.tsx +++ b/apps/hackathon/src/app/page.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from 'react-router'; +import { useNavigate, Link } from 'react-router'; import { useState } from 'react'; import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Icon } from '@iconify/react'; @@ -796,12 +796,12 @@ export default function HomePage() {
  • - Daftar - +
  • diff --git a/apps/hackathon/src/app/teams/browse/page.tsx b/apps/hackathon/src/app/teams/browse/page.tsx index b0b8aaa..4266946 100644 --- a/apps/hackathon/src/app/teams/browse/page.tsx +++ b/apps/hackathon/src/app/teams/browse/page.tsx @@ -1,8 +1,8 @@ -import { FC, ReactElement, useState, useEffect } from 'react'; +import { FC, ReactElement, useState, useEffect, useRef, useCallback } from 'react'; import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Link, useNavigate } from 'react-router'; import { - useTeams, + useInfiniteTeams, useJoinTeam, useMyTeams, ETeamVisibility, @@ -22,6 +22,9 @@ const BrowseTeamsPage: FC = (): ReactElement => { const [selectedTeamId, setSelectedTeamId] = useState(null); const [showJoinModal, setShowJoinModal] = useState(false); + // Ref for intersection observer + const loadMoreRef = useRef(null); + // Debounce search term useEffect(() => { const timer = setTimeout(() => { @@ -30,7 +33,13 @@ const BrowseTeamsPage: FC = (): ReactElement => { return () => clearTimeout(timer); }, [searchTerm]); - const { data: teamsData, isLoading } = useTeams({ + const { + data: teamsData, + isLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useInfiniteTeams({ search: debouncedSearch, city: selectedCity || undefined, visibility: ETeamVisibility.PUBLIC, @@ -44,9 +53,36 @@ const BrowseTeamsPage: FC = (): ReactElement => { mode: 'all', }); - const teams = teamsData?.data || []; + // Flatten pages into single array + const teams = teamsData?.pages.flatMap((page) => page.data) || []; const myTeams = myTeamsData?.data || []; + // Intersection Observer callback + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [target] = entries; + if (target.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + [hasNextPage, isFetchingNextPage, fetchNextPage] + ); + + // Set up intersection observer + useEffect(() => { + const element = loadMoreRef.current; + if (!element) return; + + const observer = new IntersectionObserver(handleObserver, { + root: null, + rootMargin: '100px', + threshold: 0, + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [handleObserver]); + // Helper function to check if user is a member of a team const isMyTeam = (teamId: string) => { return myTeams.some((team: any) => team.id === teamId); @@ -135,85 +171,102 @@ const BrowseTeamsPage: FC = (): ReactElement => {

    ) : ( -
    - {teams.map((team) => ( -
    - {team.name} -
    -
    - {team.logo ? ( - {team.name} - ) : ( -
    - - - -
    - )} -
    -

    - {team.name} -

    -
    -

    - {' '} - {team.city} -

    -

    - {' '} - {team.members?.length || 0} members -

    + <> +
    + {teams.map((team) => ( +
    + {team.name} +
    +
    + {team.logo ? ( + {team.name} + ) : ( +
    + + + +
    + )} +
    +

    + {team.name} +

    +
    +

    + {' '} + {team.city} +

    +

    + {' '} + {team.members?.length || 0} members +

    +
    -
    -

    - {team.description} -

    -
    - {isMyTeam(team.id) ? ( - - ) : ( - <> - {myTeams.length === 0 && - (team.members?.length || 0) < 5 && ( - - )} +

    + {team.description} +

    +
    + {isMyTeam(team.id) ? ( - - )} + ) : ( + <> + {myTeams.length === 0 && + (team.members?.length || 0) < 5 && ( + + )} + + + )} +
    -
    - ))} -
    + ))} +
    + + {/* Intersection Observer Sentinel */} +
    + {isFetchingNextPage && ( +
    + + Loading more teams... +
    + )} + {!hasNextPage && teams.length > 0 && ( +

    + No more teams to load +

    + )} +
    + )}
    diff --git a/apps/hackathon/src/middleware.ts b/apps/hackathon/src/middleware.ts index 010770d..f77c580 100644 --- a/apps/hackathon/src/middleware.ts +++ b/apps/hackathon/src/middleware.ts @@ -1,5 +1,5 @@ import { SessionUser } from '@imphnen-frontend-service/utils'; -import { hackathonApi } from '@imphnen-frontend-service/service'; +import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service'; import { LoaderFunctionArgs, redirect } from 'react-router'; const mappingPublicRoutes = [ @@ -37,9 +37,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); const pathname = url.pathname; - // Get session from local storage (via SessionUser) - const session = SessionUser.get(); - const isAuthenticated = !!session?.token?.access_token; + // Get token from cookies and user from local storage + const tokenData = SessionToken.get(); + const user = SessionUser.get(); + const isAuthenticated = !!tokenData?.token?.access_token; // Allow to access the hackathon pages without authentication if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) { @@ -71,7 +72,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => { // Skip onboarding check for onboarding routes themselves if (!mappingOnboardingRoutes.includes(pathname)) { try { - const userId = session?.user?.id; + const userId = user?.id; if (!userId) { return redirect('/auth/login'); } @@ -85,8 +86,8 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => { if (cached && (now - cached.timestamp) < CACHE_DURATION) { hasLocation = cached.hasLocation; } else { - // First check session data (faster) - if (session?.user?.location) { + // First check user data (faster) + if (user?.location) { hasLocation = true; } else { // Fetch from backend API @@ -94,8 +95,8 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => { const response = await hackathonApi.get('/users/me'); hasLocation = !!response.data?.data?.location; } catch { - // If API fails, check session data as fallback - hasLocation = !!session?.user?.location; + // If API fails, check user data as fallback + hasLocation = !!user?.location; } } @@ -113,9 +114,9 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => { } } - // Check route permissions using user data from session + // Check route permissions using user data const userPermissions = - session?.user?.role?.permissions?.map?.((perm) => perm?.name) ?? []; + user?.role?.permissions?.map?.((perm) => perm?.name) ?? []; const matchedRoute = mappingRoutePermissions.find( (route) => route.path === pathname diff --git a/libs/service/src/api/hackathon.ts b/libs/service/src/api/hackathon.ts index 263ae31..9b89a7d 100644 --- a/libs/service/src/api/hackathon.ts +++ b/libs/service/src/api/hackathon.ts @@ -16,8 +16,8 @@ export const hackathonApi = axios.create({ hackathonApi.interceptors.request.use( (config) => { const { session } = useAuthStore.getState(); - if (session?.token) { - config.headers.Authorization = `Bearer ${session.token}`; + if (session?.token?.access_token) { + config.headers.Authorization = `Bearer ${session.token.access_token}`; } return config; }, diff --git a/libs/service/src/hooks/auth/index.ts b/libs/service/src/hooks/auth/index.ts index fe4a832..ff8e87c 100644 --- a/libs/service/src/hooks/auth/index.ts +++ b/libs/service/src/hooks/auth/index.ts @@ -111,43 +111,16 @@ export const useLogin = () => { }); }; -// Email/Password Signup +// Email/Password Signup - returns message only (user needs to activate via email) export const useSignup = () => { - const { setSession } = useAuthStore(); - return useMutation({ mutationFn: async (data: SignupRequest) => { - const response = await hackathonApi.post>( + const response = await hackathonApi.post>( '/auth/signup', data ); return response.data.data; }, - onSuccess: (data) => { - setSession({ - token: data.token, - user: { - id: data.user.id, - email: data.user.email, - fullname: data.user.fullname, - phone_number: data.user.phone_number || '', - avatar: data.user.avatar || '', - birthdate: data.user.birthdate || '', - gender: data.user.gender || '', - is_active: data.user.is_active, - location: data.user.location, - bio: data.user.bio, - skills: data.user.skills, - role: { - id: '', - name: 'user', - permissions: [], - created_at: '', - updated_at: '', - }, - }, - }); - }, }); }; diff --git a/libs/service/src/hooks/teams/index.ts b/libs/service/src/hooks/teams/index.ts index a2d59bc..1506856 100644 --- a/libs/service/src/hooks/teams/index.ts +++ b/libs/service/src/hooks/teams/index.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'; import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; import { useAuthStore } from '../auth'; import type { @@ -131,6 +131,39 @@ export const useTeams = (params?: { }); }; +// Infinite scroll teams hook +const TEAMS_PAGE_SIZE = 12; + +export const useInfiniteTeams = (params?: { + city?: string; + visibility?: string; + search?: string; +}) => { + return useInfiniteQuery({ + queryKey: [...teamKeys.lists(), 'infinite', params], + queryFn: async ({ pageParam = 1 }) => { + const queryParams = new URLSearchParams(); + queryParams.append('page', String(pageParam)); + queryParams.append('limit', String(TEAMS_PAGE_SIZE)); + if (params?.search) queryParams.append('search', params.search); + if (params?.city) queryParams.append('city', params.city); + if (params?.visibility) queryParams.append('visibility', params.visibility); + + const response = await hackathonApi.get>( + `/teams/browse?${queryParams.toString()}` + ); + + const teams = response.data.data || []; + return { + data: teams, + nextPage: teams.length === TEAMS_PAGE_SIZE ? pageParam + 1 : undefined, + }; + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.nextPage, + }); +}; + export const useTeamById = (teamId: string, enabled = true) => { return useQuery({ queryKey: teamKeys.detail(teamId),