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
98 changes: 96 additions & 2 deletions apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@
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({
Expand Down Expand Up @@ -45,6 +52,93 @@
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),

Check warning on line 80 in apps/api/src/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / check

Unsafe assignment of an `any` value
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');
Expand Down
84 changes: 54 additions & 30 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Injectable,
Logger,
BadRequestException,
NotFoundException,
ConflictException,
ForbiddenException,
Expand Down Expand Up @@ -128,15 +129,6 @@ export class AuthService {
},
}
: undefined,
hiringProfile:
data.accountType === 'HIRING'
? {
create: {
organizationName: data.organizationName!,
organizationType: data.organizationType!,
},
}
: undefined,
},
include: {
developerProfile: true,
Expand Down Expand Up @@ -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) {
Expand All @@ -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({
Expand Down
37 changes: 27 additions & 10 deletions apps/web/app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 (
Expand All @@ -46,8 +64,7 @@ export default function DashboardLayout({
</main>
</SidebarInset>

{/* Render the Tour Overlay */}
<DashboardTour />
{user?.accountType !== 'SUPER_ADMIN' && <DashboardTour />}
</SidebarProvider>
);
}
12 changes: 2 additions & 10 deletions apps/web/app/(authenticated)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-destructive text-sm">
Expand All @@ -25,7 +17,7 @@ function SettingsContent() {
);
}

if (isLoading || !user || user.accountType === 'SUPER_ADMIN') {
if (isLoading || !user) {
return (
<div className="space-y-4">
<Skeleton className="h-9 w-full max-w-md" />
Expand Down
14 changes: 10 additions & 4 deletions apps/web/app/(authenticated)/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,15 +46,18 @@ export default function ExploreUsersPage() {
data,
error,
isLoading: isDataLoading,
isValidating,
} = useSWR<ExploreUsersResponse, ApiError>(
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 (
<div className="container mx-auto py-8 max-w-7xl space-y-8">
<Skeleton className="h-12 w-64" />
Expand Down Expand Up @@ -85,10 +88,13 @@ export default function ExploreUsersPage() {
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name or profession..."
className="pl-9"
className="pr-9 pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{isValidating && (
<Loader2 className="text-muted-foreground absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 animate-spin" />
)}
</div>
</div>

Expand Down
Loading
Loading