feat(hackathon): improve auth flow and UX

- 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 <a href> with <Link> components
- Add useInfiniteTeams hook for paginated team browsing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Maulana Sodiqin
2025-11-28 17:40:34 +07:00
co-authored by Claude
parent f8d592d811
commit fa30849d03
10 changed files with 363 additions and 181 deletions
+26 -3
View File
@@ -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!');
+3 -3
View File
@@ -172,12 +172,12 @@ export default function LoginPage() {
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Don't have an account?{' '}
<a
href="/auth/signup"
<Link
to="/auth/signup"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign up
</a>
</Link>
</p>
</div>
+149 -52
View File
@@ -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<typeof signupSchema>;
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<string | null>(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<SignupFormData>({
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 (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:email-check"
className="text-3xl text-green-600 dark:text-green-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Check Your Email
</h2>
<p className="text-gray-600 dark:text-gray-400">
We've sent an activation link to{' '}
<strong>{registeredEmail}</strong>
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Click the link in the email to activate your account. The link
will expire in 24 hours.
</p>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-700 dark:text-amber-300">
Don't forget to check your spam folder if you don't see the
email.
</p>
</div>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
Go to Login
</button>
</Link>
<button
onClick={() => setRegistrationSuccess(false)}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Register with different email
</button>
</div>
</div>
</div>
);
}
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 (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
@@ -98,7 +173,7 @@ export default function SignupPage() {
</div>
)}
<form onSubmit={handleEmailSignup} className="space-y-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="fullname"
@@ -109,13 +184,18 @@ export default function SignupPage() {
<input
id="fullname"
type="text"
value={fullname}
onChange={(e) => setFullname(e.target.value)}
{...register('fullname')}
placeholder="John Doe"
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.fullname ? inputErrorClass : inputNormalClass
}`}
/>
{errors.fullname && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.fullname.message}
</p>
)}
</div>
<div>
@@ -128,13 +208,18 @@ export default function SignupPage() {
<input
id="email"
type="email"
value={email}
onChange={(e) => 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 && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.email.message}
</p>
)}
</div>
<div>
@@ -147,13 +232,18 @@ export default function SignupPage() {
<input
id="password"
type="password"
value={password}
onChange={(e) => 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 && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.password.message}
</p>
)}
</div>
<div>
@@ -166,21 +256,28 @@ export default function SignupPage() {
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => 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 && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
<button
type="submit"
disabled={signupMutation.isPending}
disabled={!isValid || signupMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{signupMutation.isPending ? 'Creating account...' : 'Create Account'}
{signupMutation.isPending
? 'Creating account...'
: 'Create Account'}
</button>
</form>
@@ -207,12 +304,12 @@ export default function SignupPage() {
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Already have an account?{' '}
<a
href="/auth/login"
<Link
to="/auth/login"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign in
</a>
</Link>
</p>
</div>
+5 -3
View File
@@ -1,3 +1,5 @@
import { Link } from 'react-router';
export default function MaintenancePage() {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
@@ -9,12 +11,12 @@ export default function MaintenancePage() {
<br />
Thank you for your patience.
</p>
<a
href="/"
<Link
to="/"
className="mt-4 inline-block text-primary-600 hover:underline"
>
Back to Homepage
</a>
</Link>
</div>
</div>
);
+4 -4
View File
@@ -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() {
</a>
</li>
<li>
<a
href="/auth/signup"
<Link
to="/auth/signup"
className="hover:text-white transition-colors"
>
Daftar
</a>
</Link>
</li>
</ul>
</div>
+126 -73
View File
@@ -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<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false);
// Ref for intersection observer
const loadMoreRef = useRef<HTMLDivElement>(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 => {
</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => (
<div
key={team.id}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
>
<img
src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name}
className="w-full aspect-3/1 object-cover"
/>
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-300 text-xl">
<Icon icon="mdi:account-group" />
</span>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
{team.name}
</h3>
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
<Icon icon="mdi:map-marker" />{' '}
<span>{team.city}</span>
</p>
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
<Icon icon="mdi:account-group" />{' '}
{team.members?.length || 0} members
</p>
<>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => (
<div
key={team.id}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
>
<img
src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name}
className="w-full aspect-3/1 object-cover"
/>
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-300 text-xl">
<Icon icon="mdi:account-group" />
</span>
</div>
)}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
{team.name}
</h3>
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
<Icon icon="mdi:map-marker" />{' '}
<span>{team.city}</span>
</p>
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
<Icon icon="mdi:account-group" />{' '}
{team.members?.length || 0} members
</p>
</div>
</div>
</div>
</div>
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
{team.description}
</p>
<div className="space-y-3 mt-auto">
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
Your Team
</Button>
) : (
<>
{myTeams.length === 0 &&
(team.members?.length || 0) < 5 && (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
{team.description}
</p>
<div className="space-y-3 mt-auto">
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
View Team
Your Team
</Button>
</>
)}
) : (
<>
{myTeams.length === 0 &&
(team.members?.length || 0) < 5 && (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
View Team
</Button>
</>
)}
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
{/* Intersection Observer Sentinel */}
<div ref={loadMoreRef} className="py-8 flex justify-center">
{isFetchingNextPage && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<Icon icon="mdi:loading" className="animate-spin text-xl" />
<span>Loading more teams...</span>
</div>
)}
{!hasNextPage && teams.length > 0 && (
<p className="text-gray-500 dark:text-gray-500 text-sm">
No more teams to load
</p>
)}
</div>
</>
)}
</div>
+12 -11
View File
@@ -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