Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
312 changes: 312 additions & 0 deletions frontend/src/components/auth/OnboardingWizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../../hooks/useAuth';
import { fadeIn } from '../../lib/animations';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Unused import: fadeIn is imported but never used.

The fadeIn animation is imported from ../../lib/animations but the component uses locally-defined stepVariants instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/auth/OnboardingWizard.tsx` at line 5, The import of
fadeIn from '../../lib/animations' is unused in OnboardingWizard.tsx; either
remove the unused import line or replace the local animation usage
(stepVariants) with fadeIn where appropriate—update the import/export
accordingly and ensure there are no lint errors referencing fadeIn; target the
import statement for fadeIn and the local stepVariants usage in the
OnboardingWizard component when making the change.


const SKILL_OPTIONS = [
{ id: 'react', label: 'React / Next.js' },
{ id: 'vue', label: 'Vue / Nuxt' },
{ id: 'svelte', label: 'Svelte' },
{ id: 'node', label: 'Node.js / Express' },
{ id: 'python', label: 'Python / FastAPI' },
{ id: 'rust', label: 'Rust' },
{ id: 'solidity', label: 'Solidity / EVM' },
{ id: 'solana', label: 'Solana / Anchor' },
{ id: 'ai-ml', label: 'AI / ML' },
{ id: 'devops', label: 'DevOps / Cloud' },
{ id: 'security', label: 'Security / Audit' },
{ id: 'docs', label: 'Technical Writing' },
];

const LANG_OPTIONS = [
{ id: 'typescript', label: 'TypeScript' },
{ id: 'python', label: 'Python' },
{ id: 'rust', label: 'Rust' },
{ id: 'go', label: 'Go' },
{ id: 'solidity', label: 'Solidity' },
];

const STEPS = [
{ id: 'profile', title: 'Profile', icon: '👤' },
{ id: 'skills', title: 'Skills', icon: '🛠️' },
{ id: 'wallet', title: 'Wallet', icon: '💜' },
{ id: 'done', title: 'Done!', icon: '🎉' },
];

export function OnboardingWizard() {
const { user, updateUser } = useAuth();
const navigate = useNavigate();
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

// Step data
const [username, setUsername] = useState(user?.username ?? '');
const [bio, setBio] = useState('');
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [selectedLangs, setSelectedLangs] = useState<string[]>(['typescript']);
const [walletAddr, setWalletAddr] = useState(user?.wallet_address ?? '');
const [walletVerified, setWalletVerified] = useState(user?.wallet_verified ?? false);
Comment on lines +44 to +50
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

Stale initial state if user loads asynchronously after component mount.

State is initialized from user?.username, user?.wallet_address, and user?.wallet_verified using useState's initial value. If user is null during initial render (e.g., auth state loading) but becomes available later, these state values will remain empty/false because useState ignores subsequent changes to its initial value argument.

Consider using useEffect to sync state when user changes, or ensure this component only renders after auth state is resolved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/auth/OnboardingWizard.tsx` around lines 44 - 50,
OnboardingWizard initializes username, walletAddr, and walletVerified from user
in useState which becomes stale if user arrives after mount; add a useEffect
that runs when user changes and calls setUsername(user?.username ?? ''),
setWalletAddr(user?.wallet_address ?? ''), and
setWalletVerified(!!user?.wallet_verified) to keep state in sync (leave other
state like bio/selectedSkills/selectedLangs as-is or sync similarly if needed).


const isLastStep = step === STEPS.length - 2;
const isDone = step === STEPS.length - 1;

const toggleSkill = (id: string) => {
setSelectedSkills(prev =>
prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]
);
};

const toggleLang = (id: string) => {
setSelectedLangs(prev =>
prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]
);
};

const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1));
const back = () => setStep(s => Math.max(s - 1, 0));

const handleFinish = async () => {
setLoading(true);
setError(null);
try {
// Update user profile with all onboarding data
updateUser({
username: username || user?.username ?? '',
wallet_address: walletAddr || undefined,
wallet_verified: walletVerified,
});
Comment on lines +75 to +79
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

Collected onboarding data (bio, skills, languages) is discarded and never persisted.

Lines 46-48 collect bio, selectedSkills, and selectedLangs from user input, but handleFinish only passes username, wallet_address, and wallet_verified to updateUser. The comment on line 80 acknowledges this with "In a real app, would POST to /api/contributors/me" but:

  1. Users complete these fields expecting them to be saved
  2. The completion screen (lines 260-262) references selectedSkills.length implying it matters
  3. This creates a broken user experience where effort is lost

Either persist this data or remove the UI fields that collect it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/auth/OnboardingWizard.tsx` around lines 75 - 79,
handleFinish currently calls updateUser but omits the collected onboarding
fields (bio, selectedSkills, selectedLangs), so that data is never persisted;
modify handleFinish (and/or the updateUser call) to include bio, skills, and
languages (e.g., pass bio, selected_skills/skills, selected_langs/languages)
when calling updateUser and ensure the updateUser implementation (or backend API
payload) accepts and saves these keys, or alternatively remove the UI inputs and
related references (like the completion screen usage of selectedSkills.length)
if you choose not to persist them; update any TypeScript types/interfaces for
updateUser and the user model to include these fields so the compiler and API
payload remain consistent.

// In a real app, would POST to /api/contributors/me with skills/langs
next();
} catch(e: any) {
setError(e.message ?? 'Something went wrong');
Comment on lines +82 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Unsafe error handling: catch(e: any) with e.message access.

Using any type bypasses TypeScript's type checking. If an error is thrown that isn't an Error object (e.g., a string or null), accessing e.message could result in undefined.

A safer pattern:

catch (e: unknown) {
  setError(e instanceof Error ? e.message : 'Something went wrong');
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/auth/OnboardingWizard.tsx` around lines 82 - 83, The
catch block in OnboardingWizard.tsx uses catch(e: any) and reads e.message which
is unsafe for non-Error throws; change the catch signature to catch(e: unknown)
and set the error using a type-guard (e instanceof Error ? e.message :
'Something went wrong') when calling setError so non-Error values are handled
safely (update the catch near the setError call in the OnboardingWizard
component).

} finally {
setLoading(false);
}
};
Comment on lines +70 to +87
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

Dead code: updateUser never throws, making error handling unreachable.

Per the AuthContext.tsx implementation (lines 88-97), updateUser is synchronous and does not throw exceptions—it simply returns early if user is null. The try/catch block and error state management here will never execute, giving a false sense of error handling.

Additionally, the loading state creates visual feedback for an operation that completes synchronously and instantly.

If this component should handle real API failures in the future, updateUser would need to be refactored to be async and actually throw on failure, or this component needs a direct API call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/auth/OnboardingWizard.tsx` around lines 70 - 87,
handleFinish currently wraps a synchronous updateUser call in an async/try/catch
and toggles loading/error even though updateUser never throws; remove the async
wrapper and the try/catch/finally and the setLoading/setError state changes, so
handleFinish simply calls updateUser({ username: username || user?.username ??
'', wallet_address: walletAddr || undefined, wallet_verified: walletVerified })
and then calls next(); if instead you intend to perform an actual API call here,
make updateUser async (or replace the call with an awaited API POST) so errors
can be caught and loading/error state used.


const skipToHome = () => navigate('/', { replace: true });

const stepVariants = {
enter: (dir: number) => ({ x: dir > 0 ? 60 : -60, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir < 0 ? 60 : -60, opacity: 0 }),
};

return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
<div className="w-full max-w-lg">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Welcome to SolFoundry</h1>
<p className="text-gray-400 text-sm">Complete your contributor profile to start earning</p>
</div>

{/* Progress Steps */}
<div className="flex items-center justify-between mb-8 px-2">
{STEPS.map((s, i) => (
<React.Fragment key={s.id}>
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg transition-all ${
i <= step ? 'bg-purple-600 text-white' : 'bg-gray-800 text-gray-500'
}`}>
{i < step ? '✓' : s.icon}
</div>
<span className={`text-xs mt-1 ${
i <= step ? 'text-purple-400' : 'text-gray-600'
}`}>{s.title}</span>
</div>
{i < STEPS.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 ${
i < step ? 'bg-purple-600' : 'bg-gray-800'
}`} />
)}
</React.Fragment>
))}
</div>

{/* Step Card */}
<div className="bg-gray-900 rounded-2xl border border-gray-800 p-6 min-h-80">
<AnimatePresence mode="wait" custom={step}>
<motion.div
key={step}
custom={step}
variants={stepVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.25, ease: 'easeOut' }}
>
{/* Step 0: Profile */}
{step === 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-4">Set up your profile</h2>
<div className="space-y-4">
<div>
<label className="block text-gray-400 text-sm mb-1">Username</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder={user?.username ?? 'your_username'}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
/>
</div>
<div>
<label className="block text-gray-400 text-sm mb-1">Bio <span className="text-gray-600">(optional)</span></label>
<textarea
value={bio}
onChange={e => setBio(e.target.value)}
placeholder="Tell the community about yourself..."
rows={3}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 resize-none"
/>
</div>
{user?.avatar_url && (
<div className="flex items-center gap-3">
<img src={user.avatar_url} alt="Avatar" className="w-12 h-12 rounded-full" />
<span className="text-gray-400 text-sm">GitHub avatar</span>
</div>
)}
</div>
</div>
)}

{/* Step 1: Skills */}
{step === 1 && (
<div>
<h2 className="text-lg font-semibold text-white mb-4">Choose your skills</h2>
<p className="text-gray-500 text-xs mb-4">Select all that apply — helps match you with relevant bounties</p>
<div className="grid grid-cols-2 gap-2">
{SKILL_OPTIONS.map(skill => (
<button
key={skill.id}
onClick={() => toggleSkill(skill.id)}
className={`text-left px-3 py-2 rounded-lg text-sm font-medium transition-all ${
selectedSkills.includes(skill.id)
? 'bg-purple-600/30 border border-purple-500 text-purple-300'
: 'bg-gray-800 border border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
{selectedSkills.includes(skill.id) ? '✓ ' : ''}{skill.label}
</button>
))}
</div>
<div className="mt-4">
<p className="text-gray-500 text-xs mb-2">Preferred languages</p>
<div className="flex flex-wrap gap-2">
{LANG_OPTIONS.map(lang => (
<button
key={lang.id}
onClick={() => toggleLang(lang.id)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
selectedLangs.includes(lang.id)
? 'bg-blue-600/30 border border-blue-500 text-blue-300'
: 'bg-gray-800 border border-gray-700 text-gray-400'
}`}
>
{lang.label}
</button>
))}
</div>
</div>
</div>
)}

{/* Step 2: Wallet */}
{step === 2 && (
<div>
<h2 className="text-lg font-semibold text-white mb-4">Connect your wallet</h2>
<p className="text-gray-400 text-sm mb-4">Add your Solana wallet to receive FNDRY token rewards</p>
<div className="space-y-4">
<div>
<label className="block text-gray-400 text-sm mb-1">Solana Wallet Address</label>
<input
type="text"
value={walletAddr}
onChange={e => setWalletAddr(e.target.value)}
placeholder="ABC...XYZ"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 font-mono text-sm"
/>
Comment on lines +225 to +231
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

No validation on Solana wallet address format.

The wallet address input accepts any string without validating that it's a valid Solana public key (base58 encoded, typically 32-44 characters). Invalid addresses could cause issues when attempting to distribute tokens.

Consider adding basic format validation before allowing the user to proceed or mark the wallet as verified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/auth/OnboardingWizard.tsx` around lines 225 - 231,
The wallet address input in the OnboardingWizard component currently accepts any
string; add validation to ensure the value in walletAddr is a valid Solana
public key (base58 characters and typical length 32–44) before enabling
progression or marking verified. Implement this by validating onChange or onBlur
in OnboardingWizard: use a library (e.g., bs58 decode or solana-web3's PublicKey
constructor) to attempt decoding/constructing and catch errors, set a local
validation state (e.g., walletAddrValid) and show an inline error message and/or
disable the Next/Verify button until valid; update references to setWalletAddr
and any proceed/verify handlers to check walletAddrValid before proceeding.
Ensure the error UI is tied to the same input and that invalid addresses do not
trigger downstream distribution logic.

</div>
{walletAddr && !walletVerified && (
<button
onClick={() => setWalletVerified(true)}
className="w-full bg-purple-600 hover:bg-purple-500 text-white py-2.5 rounded-lg font-medium transition-colors"
>
Verify Ownership (Sign Message)
</button>
Comment on lines +233 to +239
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 | 🔴 Critical

Security: Fake wallet verification allows unverified wallets to be marked as verified.

The "Verify Ownership (Sign Message)" button merely sets walletVerified = true without performing any actual cryptographic signature verification. This is a placeholder that:

  1. Persists wallet_verified: true to the user profile (line 78) without proof of ownership
  2. Could allow users to claim ownership of wallets they don't control
  3. Misleads users into thinking their wallet has been cryptographically verified

For a bounty platform distributing tokens, wallet ownership verification is security-critical. At minimum, this should either:

  • Integrate with a wallet adapter (e.g., @solana/wallet-adapter-react) to request a signature
  • Be clearly disabled/hidden until real verification is implemented
  • Not persist wallet_verified: true until backend confirms signature
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/auth/OnboardingWizard.tsx` around lines 233 - 239,
The current Verify Ownership button in OnboardingWizard.tsx simply calls
setWalletVerified(true) and allows wallet_verified to be persisted without any
cryptographic proof; replace this placeholder with a real signature flow: use
the project's wallet adapter (e.g., `@solana/wallet-adapter-react`) to request a
signed message from the connected wallet, send the signature, message and
walletAddr to the backend endpoint for verification, and only call
setWalletVerified(true) and persist wallet_verified after the backend confirms
the signature is valid; alternatively, hide/disable the button in the
OnboardingWizard UI until this verification implementation exists to prevent
false verification.

)}
{walletVerified && (
<div className="flex items-center gap-2 text-green-400 text-sm">
<span>✓</span> Wallet verified
</div>
)}
<p className="text-gray-600 text-xs">
FNDRY tokens are distributed on Solana. Your wallet must support SPL tokens.
</p>
</div>
</div>
)}

{/* Step 3: Done */}
{step === 3 && (
<div className="text-center py-8">
<div className="text-5xl mb-4">🎉</div>
<h2 className="text-2xl font-bold text-white mb-2">You're all set!</h2>
<p className="text-gray-400 mb-6">
{username || user?.username}, your profile is ready.
{selectedSkills.length > 0 && (
<span> You'll be matched with <strong className="text-purple-400">{selectedSkills.length}</strong> skill areas.</span>
)}
</p>
<div className="space-y-3">
<button
onClick={skipToHome}
className="w-full bg-purple-600 hover:bg-purple-500 text-white py-3 rounded-xl font-semibold transition-colors"
>
Start Hunting Bounties →
</button>
<button
onClick={skipToHome}
className="w-full text-gray-500 hover:text-gray-300 py-2 text-sm transition-colors"
>
Browse bounties first
</button>
</div>
</div>
)}
</motion.div>
</AnimatePresence>

{/* Error */}
{error && (
<div className="mt-4 text-red-400 text-sm text-center">{error}</div>
)}
</div>

{/* Navigation */}
{!isDone && (
<div className="flex gap-3 mt-4">
{step > 0 && (
<button
onClick={back}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 py-2.5 rounded-lg font-medium transition-colors"
>
Back
</button>
)}
<button
onClick={step === STEPS.length - 2 ? handleFinish : next}
disabled={loading}
className="flex-1 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-800 text-white py-2.5 rounded-lg font-semibold transition-colors"
>
{loading ? 'Saving...' : step === STEPS.length - 2 ? 'Complete Setup' : 'Continue'}
</button>
</div>
)}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions frontend/src/components/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AuthGuard } from './AuthGuard';
export { OnboardingWizard } from './OnboardingWizard';
Loading