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() {
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() {
)}
-
@@ -207,12 +304,12 @@ export default function SignupPage() {
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.logo ? (
-

- ) : (
-
-
-
-
-
- )}
-
-
- {team.name}
-
-
-
- {' '}
- {team.city}
-
-
- {' '}
- {team.members?.length || 0} members
-
+ <>
+
+ {teams.map((team) => (
+
+

+
+
+ {team.logo ? (
+

+ ) : (
+
+
+
+
+
+ )}
+
+
+ {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),