Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ const ActionButtons = () => {
)
: false;

// Server-derived role for the current viewer. Organizers and assigned
// judges cannot join their own hackathon; surfacing the role lets us
// show the right copy instead of a broken join button.
const viewerRole = (hackathon as any)?.viewerRole as
| 'organizer'
| 'judge'
| 'participant'
| 'guest'
| undefined;
const hasConflictingRole =
viewerRole === 'organizer' || viewerRole === 'judge';
Comment on lines +49 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid 'any' type; add viewerRole to Hackathon type.

The code uses (hackathon as any)?.viewerRole which bypasses TypeScript's type safety. The viewerRole property should be added to the Hackathon type definition in lib/api/hackathons.ts to maintain type safety.

🔧 Recommended fix

Add viewerRole to the Hackathon type in lib/api/hackathons.ts:

 export type Hackathon = {
   id: string;
   name: string;
   // ... other fields
+  viewerRole?: 'organizer' | 'judge' | 'participant' | 'guest';
   // ... rest of fields
 };

Then remove the any assertion:

- const viewerRole = (hackathon as any)?.viewerRole as
-   | 'organizer'
-   | 'judge'
-   | 'participant'
-   | 'guest'
-   | undefined;
+ const viewerRole = hackathon?.viewerRole;

As per coding guidelines: "Do not use 'any' type; always search for proper Trustless Work entity types."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(landing)/hackathons/[slug]/components/header/ActionButtons.tsx around
lines 49 - 56, The code is using an any cast for viewerRole; add a viewerRole?:
'organizer' | 'judge' | 'participant' | 'guest' to the Hackathon interface/type
in lib/api/hackathons.ts (the Hackathon type) and then remove the (hackathon as
any)?.viewerRole cast in ActionButtons.tsx so viewerRole is accessed as
(hackathon)?.viewerRole; keep the existing hasConflictingRole logic (viewerRole
=== 'organizer' || viewerRole === 'judge') unchanged.


const handleJoin = withAuth(async () => {
try {
await joinMutation.mutateAsync();
Expand Down Expand Up @@ -86,7 +98,13 @@ const ActionButtons = () => {

return (
<div className='flex w-full flex-col gap-3 md:w-auto md:flex-row md:items-center'>
{!isParticipant ? (
{hasConflictingRole ? (
<div className='flex h-12 w-full items-center justify-center rounded-xl border border-white/10 bg-white/5 px-8 text-sm font-medium text-gray-300 md:w-auto'>
{viewerRole === 'organizer'
? 'You are managing this hackathon'
: 'You are judging this hackathon'}
</div>
) : !isParticipant ? (
<BoundlessButton
className='s d bg-primary hover:bg-primary/90 h-12 w-full rounded-xl px-8 font-bold text-black disabled:bg-white/5 disabled:text-white/20 md:w-auto'
icon={!isRegistrationClosed && <IconUserPlus size={20} />}
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const ParticipantsPage: React.FC = () => {
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
const [isJudgeModalOpen, setIsJudgeModalOpen] = useState(false);
const [criteria, setCriteria] = useState<
Array<{ title: string; weight: number; description?: string }>
Array<{ id: string; title: string; weight: number; description?: string }>
>([]);
const [isLoadingCriteria, setIsLoadingCriteria] = useState(false);

Expand Down Expand Up @@ -194,6 +194,9 @@ const ParticipantsPage: React.FC = () => {
if (response.success) {
setCriteria(
response.data?.judgingCriteria?.map(criterion => ({
// Persisted criteria always have an id (server enforces it).
// Fall back to name only for resilience against historic data.
id: criterion.id ?? criterion.name ?? '',
title: criterion.name || '',
weight: criterion.weight || 0,
description: criterion.description || '',
Expand Down
18 changes: 18 additions & 0 deletions app/auth/check-email/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,29 @@
import { MailIcon } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { BoundlessButton } from '@/components/buttons';

const POST_VERIFY_KEY = 'boundless:postVerifyCallbackUrl';

const CheckEmail = () => {
const searchParams = useSearchParams();
const email = searchParams.get('email');
const callbackUrl = searchParams.get('callbackUrl');

// The verification email link Better Auth sends does not preserve query
// params, so stash the desired post-verify destination here. The
// /auth/verify-email page reads this back after the token is consumed.
useEffect(() => {
if (typeof window === 'undefined') return;
if (callbackUrl && callbackUrl.startsWith('/')) {
try {
window.localStorage.setItem(POST_VERIFY_KEY, callbackUrl);
} catch {
// ignore quota / private mode errors
}
}
}, [callbackUrl]);

return (
<div className='flex min-h-screen items-center justify-center p-4'>
Expand Down
18 changes: 17 additions & 1 deletion app/auth/verify-email/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { toast } from 'sonner';

const POST_VERIFY_KEY = 'boundless:postVerifyCallbackUrl';

const VerifyEmail = () => {
const router = useRouter();
const searchParams = useSearchParams();
Expand All @@ -16,7 +18,21 @@ const VerifyEmail = () => {
fetchOptions: {
onSuccess: () => {
toast.success('Email verified successfully');
router.push('/');
// Honor a post-verify destination stashed by the signup flow
// (e.g. a judge invitation page). Same-origin paths only.
let target = '/';
if (typeof window !== 'undefined') {
try {
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
if (stashed && stashed.startsWith('/')) {
target = stashed;
}
window.localStorage.removeItem(POST_VERIFY_KEY);
} catch {
// ignore storage errors
}
}
router.push(target);
Comment on lines +21 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Insufficient open redirect protection.

The validation stashed.startsWith('/') is not sufficient to prevent open redirects. Protocol-relative URLs like //evil.com or edge cases like /\evil.com may bypass this check in some browsers.

🔒 Recommended fix for robust path validation
           let target = '/';
           if (typeof window !== 'undefined') {
             try {
               const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
-              if (stashed && stashed.startsWith('/')) {
+              if (stashed && stashed.startsWith('/') && !stashed.startsWith('//')) {
                 target = stashed;
               }
               window.localStorage.removeItem(POST_VERIFY_KEY);

Or use a more robust URL validation:

           let target = '/';
           if (typeof window !== 'undefined') {
             try {
               const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
-              if (stashed && stashed.startsWith('/')) {
-                target = stashed;
-              }
+              if (stashed) {
+                try {
+                  const url = new URL(stashed, window.location.origin);
+                  if (url.origin === window.location.origin) {
+                    target = url.pathname + url.search + url.hash;
+                  }
+                } catch {
+                  // Invalid URL, ignore
+                }
+              }
               window.localStorage.removeItem(POST_VERIFY_KEY);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Honor a post-verify destination stashed by the signup flow
// (e.g. a judge invitation page). Same-origin paths only.
let target = '/';
if (typeof window !== 'undefined') {
try {
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
if (stashed && stashed.startsWith('/')) {
target = stashed;
}
window.localStorage.removeItem(POST_VERIFY_KEY);
} catch {
// ignore storage errors
}
}
router.push(target);
// Honor a post-verify destination stashed by the signup flow
// (e.g. a judge invitation page). Same-origin paths only.
let target = '/';
if (typeof window !== 'undefined') {
try {
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
if (stashed) {
try {
const url = new URL(stashed, window.location.origin);
if (url.origin === window.location.origin) {
target = url.pathname + url.search + url.hash;
}
} catch {
// Invalid URL, ignore
}
}
window.localStorage.removeItem(POST_VERIFY_KEY);
} catch {
// ignore storage errors
}
}
router.push(target);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/auth/verify-email/page.tsx` around lines 21 - 35, The post-verify
redirect reads a stashed string from window.localStorage (POST_VERIFY_KEY) and
currently only checks stashed.startsWith('/'), which can be bypassed by
protocol-relative or malformed paths; instead parse and validate the value
before calling router.push: construct a URL using the page origin (e.g. new
URL(stashed, location.origin)) or reject values that start with '//' or contain
backslashes after the initial slash, then ensure the resolved URL's origin
equals location.origin and its pathname starts with '/' before assigning target
and calling router.push; update the logic around POST_VERIFY_KEY, stashed, and
router.push accordingly.

},
onError: () => {
toast.error('Failed to verify email');
Expand Down
49 changes: 49 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -618,3 +618,52 @@ input:-webkit-autofill:active {
-webkit-text-fill-color: white !important; /* Optional: Customize text color */
transition: background-color 5000s ease-in-out 0s; /* Optional: Smooth transition */
}

/* Judge portal: score slider */
.judge-slider {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 6px;
background: linear-gradient(
to right,
#2eedaa 0%,
#2eedaa var(--judge-slider-pct, 0%),
rgba(255, 255, 255, 0.08) var(--judge-slider-pct, 0%),
rgba(255, 255, 255, 0.08) 100%
);
border-radius: 9999px;
outline: none;
cursor: pointer;
}
.judge-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 9999px;
background: #2eedaa;
border: 2px solid #030303;
box-shadow:
0 0 0 1px rgba(46, 237, 170, 0.4),
0 2px 6px rgba(0, 0, 0, 0.6);
transition: transform 120ms ease;
}
.judge-slider:active::-webkit-slider-thumb,
.judge-slider:focus-visible::-webkit-slider-thumb {
transform: scale(1.1);
}
.judge-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 9999px;
background: #2eedaa;
border: 2px solid #030303;
box-shadow:
0 0 0 1px rgba(46, 237, 170, 0.4),
0 2px 6px rgba(0, 0, 0, 0.6);
}
.judge-slider::-moz-range-track {
background: transparent;
height: 6px;
}
Loading
Loading