Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 22 additions & 5 deletions apps/web/app/(auth)/login/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Providers } from '@/app/providers'
import LoginPage from './page'

// ==========================================
Expand All @@ -17,6 +18,10 @@ vi.mock('./CharactersScene', () => ({
CharactersScene: () => <div data-testid="characters-scene" />,
}))

function renderWithProviders(ui: React.ReactElement) {
return render(<Providers>{ui}</Providers>)
}

// ==========================================
// TESTS
// ==========================================
Expand All @@ -27,14 +32,14 @@ describe('LoginPage', () => {
})

it('renders the Google and email options', () => {
render(<LoginPage />)
renderWithProviders(<LoginPage />)

expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
})

it('starts the Google handshake and returns to role selection', async () => {
render(<LoginPage />)
renderWithProviders(<LoginPage />)

fireEvent.click(screen.getByRole('button', { name: /continue with google/i }))

Expand All @@ -47,7 +52,7 @@ describe('LoginPage', () => {
})

it('shows a redirect notice while the handshake starts', async () => {
render(<LoginPage />)
renderWithProviders(<LoginPage />)

fireEvent.click(screen.getByRole('button', { name: /continue with google/i }))

Expand All @@ -57,18 +62,30 @@ describe('LoginPage', () => {
it('surfaces a failure to start the handshake', async () => {
socialMock.mockResolvedValue({ error: { message: 'Provider unavailable' } })

render(<LoginPage />)
renderWithProviders(<LoginPage />)
fireEvent.click(screen.getByRole('button', { name: /continue with google/i }))

expect(await screen.findByRole('alert')).toHaveTextContent('Provider unavailable')
})

it('does not fake a session when the email form is submitted', async () => {
render(<LoginPage />)
renderWithProviders(<LoginPage />)

fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'user@example.com' } })
fireEvent.click(screen.getByRole('button', { name: /continue with email/i }))

expect(await screen.findByRole('alert')).toHaveTextContent(/isn't available yet/i)
expect(socialMock).not.toHaveBeenCalled()
})

it('validates email format with Zod schema', async () => {
renderWithProviders(<LoginPage />)

fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'invalid-email' } })
fireEvent.click(screen.getByRole('button', { name: /continue with email/i }))

expect(await screen.findByRole('alert')).toHaveTextContent(
/please enter a valid email address/i,
)
})
})
125 changes: 60 additions & 65 deletions apps/web/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
'use client'

import { zodResolver } from '@hookform/resolvers/zod'
import { ArrowRight, Loader2 } from 'lucide-react'
import { useState, type ChangeEvent, type FormEvent } from 'react'
import { authClient } from '@/lib/auth/client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'

import { FormField } from '@/components/ui/form-field'
import { useSocialSignIn } from '@/lib/hooks/use-auth'
import { loginSchema, type LoginInput } from '@/lib/schemas/auth.schema'
import { CharactersScene, type CharacterSceneState } from './CharactersScene'
import { sound } from './audio'

Expand Down Expand Up @@ -42,18 +47,22 @@ export default function LoginPage() {
})

const [isSignUp, setIsSignUp] = useState(false)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [isRedirecting, setIsRedirecting] = useState(false)
const [errorMsg, setErrorMsg] = useState('')

const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value
setEmail(val)
setState((s) => ({ ...s, emailLength: val.length }))
sound.playKeystroke(val.length)
setErrorMsg('')
}
const socialSignInMutation = useSocialSignIn()

const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
name: '',
email: '',
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

default values can be a seperate object outside, to keep the code lean

})

const failWith = (message: string) => {
setIsRedirecting(false)
Expand All @@ -62,12 +71,7 @@ export default function LoginPage() {
sound.playError()
}

/**
* Email sign-in is not wired up yet — Google is the only provider. The fields
* stay in place for a later change, but submitting must never fake a session.
*/
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const onSubmit = () => {
failWith("Email sign-in isn't available yet — continue with Google.")
}

Expand All @@ -77,15 +81,15 @@ export default function LoginPage() {
setState((s) => ({ ...s, isError: false }))
sound.playPop(520)

const { error } = await authClient.signIn.social({
provider: 'google',
callbackURL: '/role-select',
})

// On success the browser navigates to Google, so this only runs when the
// handshake could not be started at all.
if (error) {
failWith(error.message ?? 'Could not start Google sign-in. Please try again.')
try {
await socialSignInMutation.mutateAsync({
provider: 'google',
callbackURL: '/role-select',
})
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Could not start Google sign-in. Please try again.'
failWith(message)
}
}

Expand Down Expand Up @@ -141,59 +145,50 @@ export default function LoginPage() {
</div>

{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{isSignUp && (
<div className="space-y-1.5 text-left">
<label
htmlFor="name-input"
className="text-xs font-semibold text-zinc-700 block"
>
Name
</label>
<FormField label="Name" htmlFor="name-input" error={errors.name?.message}>
<input
id="name-input"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
{...register('name')}
onFocus={() => {
setState((s) => ({ ...s, focusedField: 'email' }))
sound.playPop(380)
}}
onBlur={() => setState((s) => ({ ...s, focusedField: 'none' }))}
placeholder="Your full name"
className="w-full px-3.5 py-2.5 rounded-xl border border-zinc-200 text-sm focus:outline-none focus:ring-2 focus:ring-zinc-900/10 focus:border-zinc-900 transition bg-zinc-50/50 hover:bg-zinc-50"
className="w-full px-3.5 py-2.5 rounded-xl border border-zinc-200 text-sm focus:outline-none focus:ring-2 focus:ring-zinc-900/10 focus:border-zinc-900 transition bg-zinc-50/50 hover:bg-zinc-50 text-zinc-900"
/>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lets use the shadcn input, also ig it has inbuilt support for react-hook-form iirc

</div>
</FormField>
)}

{/* Email Field */}
<div className="space-y-1.5 text-left">
<label
htmlFor="email-input"
className="text-xs font-semibold text-zinc-700 block"
>
Email
</label>
<div className="relative">
<input
id="email-input"
type="email"
value={email}
onChange={handleEmailChange}
onFocus={() => {
setState((s) => ({ ...s, focusedField: 'email' }))
sound.playPop(420)
}}
onBlur={() => setState((s) => ({ ...s, focusedField: 'none' }))}
placeholder="Enter your email"
className={`w-full px-3.5 py-2.5 rounded-xl border text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none transition bg-zinc-50/40 hover:bg-zinc-50 ${
state.focusedField === 'email'
? 'border-zinc-900 ring-2 ring-zinc-900/10 bg-white'
: 'border-zinc-200'
}`}
/>
</div>
</div>
<FormField label="Email" htmlFor="email-input" error={errors.email?.message}>
<input
id="email-input"
type="email"
{...register('email', {
onChange: (e) => {
const val = e.target.value
setState((s) => ({ ...s, emailLength: val.length }))
sound.playKeystroke(val.length)
setErrorMsg('')
},
})}
onFocus={() => {
setState((s) => ({ ...s, focusedField: 'email' }))
sound.playPop(420)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

whats 420 here? magic number, please put it in a constant, and why are we playing sounds?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

420 Hz is the pitch for the playful sound effect. I can pull it into a named constant or disable the sounds if you’d rather keep it silent!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yeah lets keep it silent

}}
onBlur={() => setState((s) => ({ ...s, focusedField: 'none' }))}
placeholder="Enter your email"
className={`w-full px-3.5 py-2.5 rounded-xl border text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none transition bg-zinc-50/40 hover:bg-zinc-50 ${
state.focusedField === 'email'
? 'border-zinc-900 ring-2 ring-zinc-900/10 bg-white'
: 'border-zinc-200'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lets avoid ternaries, can u use classNames here from shadcn package?

}`}
/>
</FormField>

{/* Error banner */}
{errorMsg && (
Expand Down
15 changes: 10 additions & 5 deletions apps/web/app/(auth)/role-select/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError } from '@/lib/api-client'
import { Providers } from '@/app/providers'
import RoleSelectPage from './page'

// ==========================================
Expand All @@ -23,6 +24,10 @@ vi.mock('@/lib/api-client', async (importOriginal) => {
return { ...actual, apiFetch: apiFetchMock }
})

function renderWithProviders(ui: React.ReactElement) {
return render(<Providers>{ui}</Providers>)
}

// ==========================================
// HELPERS
// ==========================================
Expand All @@ -42,7 +47,7 @@ describe('RoleSelectPage', () => {
})

it('persists the student role before navigating', async () => {
render(<RoleSelectPage />)
renderWithProviders(<RoleSelectPage />)

clickRole('student')

Expand All @@ -56,7 +61,7 @@ describe('RoleSelectPage', () => {
})

it('persists the recruiter role before navigating', async () => {
render(<RoleSelectPage />)
renderWithProviders(<RoleSelectPage />)

clickRole('recruiter')

Expand All @@ -72,7 +77,7 @@ describe('RoleSelectPage', () => {
it('sends an expired session back to sign in', async () => {
apiFetchMock.mockRejectedValue(new ApiError(401, 'Your session has expired.'))

render(<RoleSelectPage />)
renderWithProviders(<RoleSelectPage />)
clickRole('student')

await waitFor(() => expect(pushMock).toHaveBeenCalledWith('/login'))
Expand All @@ -82,7 +87,7 @@ describe('RoleSelectPage', () => {
it('reports a failed save and stays put', async () => {
apiFetchMock.mockRejectedValue(new ApiError(500, 'Something went wrong'))

render(<RoleSelectPage />)
renderWithProviders(<RoleSelectPage />)
clickRole('student')

expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong')
Expand All @@ -92,7 +97,7 @@ describe('RoleSelectPage', () => {
it('reports a network failure in plain language', async () => {
apiFetchMock.mockRejectedValue(new TypeError('Failed to fetch'))

render(<RoleSelectPage />)
renderWithProviders(<RoleSelectPage />)
clickRole('student')

expect(await screen.findByRole('alert')).toHaveTextContent(/check your connection/i)
Expand Down
7 changes: 5 additions & 2 deletions apps/web/app/(auth)/role-select/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { motion } from 'motion/react'
import { ArrowRight, Check } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, type MouseEvent } from 'react'
import { ApiError, apiFetch } from '@/lib/api-client'
import { ApiError } from '@/lib/api-client'
import { useSetUserRole } from '@/lib/hooks/use-auth'

export default function RoleSelectPage() {
const router = useRouter()
Expand Down Expand Up @@ -37,6 +38,8 @@ export default function RoleSelectPage() {
return () => clearInterval(interval)
}, [])

const setRoleMutation = useSetUserRole()

/**
* Persists the chosen role, then moves on to the matching dashboard.
*
Expand All @@ -51,7 +54,7 @@ export default function RoleSelectPage() {
setErrorMsg('')

try {
await apiFetch('/api/users/me/role', { method: 'PATCH', body: { role } })
await setRoleMutation.mutateAsync(role)
} catch (error) {
// An expired session cannot be recovered here — send them back to sign in.
if (error instanceof ApiError && error.status === 401) {
Expand Down
Loading
Loading