diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 2507abf..451aa7e 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -6,15 +6,22 @@ import { SessionService } from './session.service'; import { getQueueToken } from '@nestjs/bullmq'; import { ForbiddenException } from '@nestjs/common'; import { randomBytes, scryptSync } from 'crypto'; +import { signupRequestSchema } from '@repo/contracts'; describe('AuthService', () => { let service: AuthService; - let prisma: { user: { findUnique: jest.Mock; create: jest.Mock } }; + let prisma: { + user: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock }; + }; let sessions: { createSession: jest.Mock; deleteSession: jest.Mock }; beforeEach(async () => { prisma = { - user: { findUnique: jest.fn(), create: jest.fn() }, + user: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, }; sessions = { createSession: jest.fn(), deleteSession: jest.fn() }; const module: TestingModule = await Test.createTestingModule({ @@ -45,6 +52,93 @@ describe('AuthService', () => { expect(service).toBeDefined(); }); + it('creates a hiring account without requiring organization details', async () => { + const signupData = signupRequestSchema.parse({ + email: 'recruiter@example.com', + password: 'Password123!', + accountType: 'HIRING', + }); + prisma.user.findUnique.mockResolvedValue(null); + prisma.user.create.mockResolvedValue({ + id: '00000000-0000-4000-8000-000000000001', + email: 'recruiter@example.com', + accountType: 'HIRING', + developerProfile: null, + hiringProfile: null, + }); + + await expect(service.signup(signupData)).resolves.toMatchObject({ + user: { + email: 'recruiter@example.com', + role: 'ORG_ADMIN', + }, + }); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: { + email: 'recruiter@example.com', + passwordHash: expect.any(String), + accountType: 'HIRING', + isConfirmed: false, + developerProfile: undefined, + }, + include: { + developerProfile: true, + hiringProfile: true, + }, + }); + }); + + it('creates the hiring profile when onboarding is submitted', async () => { + prisma.user.findUnique.mockResolvedValue({ + id: '00000000-0000-4000-8000-000000000001', + email: 'recruiter@example.com', + accountType: 'HIRING', + isConfirmed: true, + hasSeenDashboardTour: false, + developerProfile: null, + hiringProfile: null, + }); + prisma.user.update.mockResolvedValue({ + id: '00000000-0000-4000-8000-000000000001', + email: 'recruiter@example.com', + accountType: 'HIRING', + isConfirmed: true, + hasSeenDashboardTour: false, + developerProfile: null, + hiringProfile: { + id: '00000000-0000-4000-8000-000000000002', + organizationName: 'Acme Inc.', + organizationType: 'COMPANY', + jobTitle: 'Talent Lead', + linkedinUrl: null, + organizationWebsiteUrl: null, + }, + }); + + await service.updateProfile('00000000-0000-4000-8000-000000000001', { + organizationName: 'Acme Inc.', + organizationType: 'COMPANY', + jobTitle: 'Talent Lead', + }); + + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + hiringProfile: { + create: { + organizationName: 'Acme Inc.', + organizationType: 'COMPANY', + jobTitle: 'Talent Lead', + linkedinUrl: undefined, + organizationWebsiteUrl: undefined, + }, + }, + }, + }), + ); + }); + it('does not create a session for a suspended account with valid credentials', async () => { const password = 'Password123!'; const salt = randomBytes(16).toString('hex'); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index c9f6512..a32f292 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, + BadRequestException, NotFoundException, ConflictException, ForbiddenException, @@ -128,15 +129,6 @@ export class AuthService { }, } : undefined, - hiringProfile: - data.accountType === 'HIRING' - ? { - create: { - organizationName: data.organizationName!, - organizationType: data.organizationType!, - }, - } - : undefined, }, include: { developerProfile: true, @@ -529,19 +521,33 @@ export class AuthService { personalWebsiteUrl?: string | null; }; }; - hiringProfile?: { - update: { - organizationName?: string; - organizationType?: - | 'COMPANY' - | 'AGENCY' - | 'INDIVIDUAL' - | 'FREELANCE_CLIENT'; - jobTitle?: string | null; - linkedinUrl?: string | null; - organizationWebsiteUrl?: string | null; - }; - }; + hiringProfile?: + | { + update: { + organizationName?: string; + organizationType?: + | 'COMPANY' + | 'AGENCY' + | 'INDIVIDUAL' + | 'FREELANCE_CLIENT'; + jobTitle?: string | null; + linkedinUrl?: string | null; + organizationWebsiteUrl?: string | null; + }; + } + | { + create: { + organizationName: string; + organizationType: + | 'COMPANY' + | 'AGENCY' + | 'INDIVIDUAL' + | 'FREELANCE_CLIENT'; + jobTitle?: string | null; + linkedinUrl?: string | null; + organizationWebsiteUrl?: string | null; + }; + }; } = {}; if (data.hasSeenDashboardTour !== undefined) { @@ -566,15 +572,33 @@ export class AuthService { }, }; } else if (user.accountType === 'HIRING') { - updatePayload.hiringProfile = { - update: { - organizationName: data.organizationName, - organizationType: data.organizationType, - jobTitle: data.jobTitle, - linkedinUrl: data.linkedinUrl, - organizationWebsiteUrl: data.organizationWebsiteUrl, - }, + const hiringProfileData = { + organizationName: data.organizationName, + organizationType: data.organizationType, + jobTitle: data.jobTitle, + linkedinUrl: data.linkedinUrl, + organizationWebsiteUrl: data.organizationWebsiteUrl, }; + + if (user.hiringProfile) { + updatePayload.hiringProfile = { + update: hiringProfileData, + }; + } else if (data.organizationName && data.organizationType) { + updatePayload.hiringProfile = { + create: { + organizationName: data.organizationName, + organizationType: data.organizationType, + jobTitle: data.jobTitle, + linkedinUrl: data.linkedinUrl, + organizationWebsiteUrl: data.organizationWebsiteUrl, + }, + }; + } else { + throw new BadRequestException( + 'Organization name and type are required to create a hiring profile', + ); + } } const updatedUser = await this.prisma.user.update({ diff --git a/apps/web/app/(authenticated)/layout.tsx b/apps/web/app/(authenticated)/layout.tsx index 13dfa06..ea66ed8 100644 --- a/apps/web/app/(authenticated)/layout.tsx +++ b/apps/web/app/(authenticated)/layout.tsx @@ -1,13 +1,13 @@ 'use client'; import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { useUser } from '@/hooks/use-auth'; import { isProfileComplete } from '@/lib/profile'; import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'; import { AppSidebar } from '@/components/app-sidebar'; import { TopNavbar } from '@/components/top-navbar'; -import { DashboardTour } from '@/components/dashboard-tour'; // <-- Import it here +import { DashboardTour } from '@/components/dashboard-tour'; import { Loader2 } from 'lucide-react'; export default function DashboardLayout({ @@ -17,16 +17,34 @@ export default function DashboardLayout({ }) { const { user, isLoading } = useUser({ redirectOnUnauthenticated: true }); const router = useRouter(); + const pathname = usePathname(); const [isRedirecting, setIsRedirecting] = useState(false); useEffect(() => { - if (!isLoading && user) { - if (!isProfileComplete(user)) { - setIsRedirecting(true); - router.replace('/onboarding'); - } + if (isLoading || !user) return; + + if (!isProfileComplete(user)) { + setIsRedirecting(true); + router.replace('/onboarding'); + return; } - }, [user, isLoading, router]); + + const hasSeenTourLocally = + localStorage.getItem(`hasSeenDashboardTour-${user.id}`) === 'true'; + const shouldStartTourOnDashboard = + user.accountType !== 'SUPER_ADMIN' && + !user.hasSeenDashboardTour && + !hasSeenTourLocally && + pathname !== '/dashboard'; + + if (shouldStartTourOnDashboard) { + setIsRedirecting(true); + router.replace('/dashboard'); + return; + } + + setIsRedirecting(false); + }, [user, isLoading, pathname, router]); if (isLoading || isRedirecting || (user && !isProfileComplete(user))) { return ( @@ -46,8 +64,7 @@ export default function DashboardLayout({ - {/* Render the Tour Overlay */} - + {user?.accountType !== 'SUPER_ADMIN' && } ); } diff --git a/apps/web/app/(authenticated)/settings/page.tsx b/apps/web/app/(authenticated)/settings/page.tsx index f49395e..e0c67a7 100644 --- a/apps/web/app/(authenticated)/settings/page.tsx +++ b/apps/web/app/(authenticated)/settings/page.tsx @@ -1,22 +1,14 @@ 'use client'; -import { Suspense, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { Suspense } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; import { SettingsTabs } from '@/components/settings-tabs'; import { BackLink } from '@/components/back-link'; import { useUser } from '@/hooks/use-auth'; function SettingsContent() { - const router = useRouter(); const { user, isLoading, error } = useUser(); - useEffect(() => { - if (user?.accountType === 'SUPER_ADMIN') { - router.replace('/admin'); - } - }, [router, user?.accountType]); - if (error) { return (

@@ -25,7 +17,7 @@ function SettingsContent() { ); } - if (isLoading || !user || user.accountType === 'SUPER_ADMIN') { + if (isLoading || !user) { return (

diff --git a/apps/web/app/(authenticated)/users/page.tsx b/apps/web/app/(authenticated)/users/page.tsx index 8d08556..4d3fa64 100644 --- a/apps/web/app/(authenticated)/users/page.tsx +++ b/apps/web/app/(authenticated)/users/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import useSWR from 'swr'; -import { Search, Users } from 'lucide-react'; +import { Loader2, Search, Users } from 'lucide-react'; import { fetcher, ApiError } from '@/lib/api'; import { useUser } from '@/hooks/use-auth'; import { Input } from '@/components/ui/input'; @@ -46,15 +46,18 @@ export default function ExploreUsersPage() { data, error, isLoading: isDataLoading, + isValidating, } = useSWR( isAuthorized ? `/users/explore?page=1&limit=20${debouncedSearch ? `&search=${encodeURIComponent(debouncedSearch)}` : ''}` : null, fetcher, + { keepPreviousData: true }, ); - // Render Skeleton while checking authorization or loading data - if (isAuthLoading || (isAuthorized && isDataLoading)) { + // Only replace the page for its initial load. Search revalidation keeps the + // input mounted so typing focus and cursor position are preserved. + if (isAuthLoading || (isAuthorized && isDataLoading && !data)) { return (
@@ -85,10 +88,13 @@ export default function ExploreUsersPage() { setSearch(e.target.value)} /> + {isValidating && ( + + )}
diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx index 50f43f4..b61b278 100644 --- a/apps/web/app/signup/page.tsx +++ b/apps/web/app/signup/page.tsx @@ -14,26 +14,12 @@ import { ApiError } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; const ACCOUNT_TYPE_OPTIONS = [ { value: 'DEVELOPER', label: 'Developer' }, { value: 'HIRING', label: 'Recruiter / Hiring' }, ] as const; -const ORGANIZATION_TYPE_OPTIONS = [ - { value: 'COMPANY', label: 'Company' }, - { value: 'AGENCY', label: 'Agency' }, - { value: 'INDIVIDUAL', label: 'Individual' }, - { value: 'FREELANCE_CLIENT', label: 'Freelance Client' }, -] as const; - export default function SignupPage() { const [isSubmitting, setIsSubmitting] = useState(false); const router = useRouter(); @@ -41,7 +27,6 @@ export default function SignupPage() { const { register, handleSubmit, - watch, control, formState: { errors }, } = useForm({ @@ -50,8 +35,6 @@ export default function SignupPage() { shouldUnregister: true, }); - const accountType = watch('accountType', 'DEVELOPER'); - const onSubmit = async (data: SignupRequest) => { setIsSubmitting(true); try { @@ -146,55 +129,6 @@ export default function SignupPage() { )} - {accountType === 'HIRING' && ( - <> -
- - - {errors.organizationName && ( -

- {errors.organizationName.message} -

- )} -
- -
- - ( - - )} - /> - {errors.organizationType && ( -

- {errors.organizationType.message} -

- )} -
- - )} -