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
2 changes: 2 additions & 0 deletions backend/apps/cloud/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { isPrimaryNode, isPrimaryClusterNode } from './common/utils'
import { OrganisationModule } from './organisation/organisation.module'
import { RevenueModule } from './revenue/revenue.module'
import { ToolsModule } from './tools/tools.module'
import { PendingInvitationModule } from './pending-invitation/pending-invitation.module'

const modules = [
SentryModule.forRoot(),
Expand Down Expand Up @@ -90,6 +91,7 @@ const modules = [
OrganisationModule,
RevenueModule,
ToolsModule,
PendingInvitationModule,
]

@Module({
Expand Down
245 changes: 244 additions & 1 deletion backend/apps/cloud/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import {
Delete,
Body,
Ip,
Inject,
forwardRef,
ConflictException,
HttpCode,
HttpStatus,
Get,
Param,
UnauthorizedException,
BadRequestException,
NotFoundException,
Headers,
} from '@nestjs/common'
import {
Expand Down Expand Up @@ -50,6 +53,7 @@ import {
SSOUnlinkDto,
SSOProviders,
SSOLinkWithPasswordDto,
RegisterInvitationRequestDto,
} from './dtos'
import {
JwtAccessTokenGuard,
Expand All @@ -58,6 +62,9 @@ import {
} from './guards'
import { ProjectService } from '../project/project.service'
import { trackCustom } from '../common/analytics'
import { PendingInvitationService } from '../pending-invitation/pending-invitation.service'
import { PendingInvitationType } from '../pending-invitation/pending-invitation.entity'
import { OrganisationService } from '../organisation/organisation.service'

const OAUTH_RATE_LIMIT = 15

Expand All @@ -78,6 +85,9 @@ export class AuthController {
private readonly userService: UserService,
private readonly authService: AuthService,
private readonly projectService: ProjectService,
private readonly pendingInvitationService: PendingInvitationService,
@Inject(forwardRef(() => OrganisationService))
private readonly organisationService: OrganisationService,
) {}

@ApiOperation({ summary: 'Register a new user' })
Expand Down Expand Up @@ -122,6 +132,16 @@ export class AuthController {
body.password,
)

const redeemedCount =
await this.authService.redeemPendingInvitations(newUser)

if (redeemedCount > 0) {
await this.userService.updateUser(newUser.id, {
registeredViaInvitation: true,
hasCompletedOnboarding: true,
})
}

await trackCustom(ip, headers['user-agent'], {
ev: 'SIGNUP',
meta: {
Expand All @@ -131,9 +151,24 @@ export class AuthController {

const jwtTokens = await this.authService.generateJwtTokens(newUser.id, true)

const updatedUser =
redeemedCount > 0
? await this.userService.findUserById(newUser.id)
: newUser

if (redeemedCount > 0) {
const [sharedProjects, organisationMemberships] = await Promise.all([
this.authService.getSharedProjectsForUser(newUser.id),
this.userService.getOrganisationsForUser(newUser.id),
])

updatedUser.sharedProjects = sharedProjects
updatedUser.organisationMemberships = organisationMemberships
}

return {
...jwtTokens,
user: this.userService.omitSensitiveData(newUser),
user: this.userService.omitSensitiveData(updatedUser),
totalMonthlyEvents: 0,
}
}
Expand Down Expand Up @@ -434,6 +469,214 @@ export class AuthController {
await this.authService.logoutAll(user.id)
}

@ApiOperation({ summary: 'Get pending invitation details' })
@ApiOkResponse({ description: 'Invitation details returned' })
@Public()
@Get('invitation/:id')
public async getInvitationDetails(@Param('id') id: string) {
const invitation = await this.pendingInvitationService.findById(id)

if (!invitation) {
throw new NotFoundException('Invitation not found or has expired')
}

const inviter = await this.userService.findUserById(invitation.inviterId)

let targetName = ''

if (
invitation.type === PendingInvitationType.PROJECT_SHARE &&
invitation.projectId
) {
const project = await this.projectService.findOne({
where: { id: invitation.projectId },
})

if (!project) {
await this.pendingInvitationService.delete(invitation.id)
throw new NotFoundException('Invitation not found or has expired')
}

targetName = project.name
} else if (
invitation.type === PendingInvitationType.ORGANISATION_MEMBER &&
invitation.organisationId
) {
const organisation = await this.organisationService.findOne({
where: { id: invitation.organisationId },
})

if (!organisation) {
await this.pendingInvitationService.delete(invitation.id)
throw new NotFoundException('Invitation not found or has expired')
}

targetName = organisation.name
}

return {
id: invitation.id,
email: invitation.email,
type: invitation.type,
role: invitation.role,
inviterEmail: inviter?.email || '',
targetName,
}
}

@ApiBearerAuth()
@ApiOperation({
summary: 'Claim a pending invitation for an authenticated user',
})
@ApiOkResponse({ description: 'Invitation claimed' })
@UseGuards(AuthenticationGuard)
@Post('invitation/:id/claim')
public async claimInvitation(
@Param('id') id: string,
@CurrentUserId() userId: string,
) {
const invitation = await this.pendingInvitationService.findById(id)

if (!invitation) {
throw new NotFoundException('Invitation not found or has expired')
}

const user = await this.userService.findOne({
where: { id: userId },
})

if (!user) {
throw new UnauthorizedException()
}

if (invitation.email !== user.email) {
throw new BadRequestException(
'This invitation was sent to a different email address',
)
}

await this.authService.redeemPendingInvitations(user)

return { success: true }
}

@ApiOperation({ summary: 'Register a new user via invitation' })
@ApiCreatedResponse({
description: 'User registered via invitation',
type: RegisterResponseDto,
})
@Public()
@Post('register/invitation')
public async registerViaInvitation(
@Body() body: RegisterInvitationRequestDto,
@I18n() i18n: I18nContext,
@Headers() headers: Record<string, string>,
@Ip() requestIp: string,
) {
const ip = getIPFromHeaders(headers) || requestIp || ''

await checkRateLimit(ip, 'register', 5)

const invitation = await this.pendingInvitationService.findById(
body.pendingInvitationId,
)

if (!invitation) {
throw new NotFoundException('Invitation not found or has expired')
}

if (invitation.email !== body.email) {
throw new BadRequestException('Email does not match the invitation')
}

if (
invitation.type === PendingInvitationType.PROJECT_SHARE &&
invitation.projectId
) {
const project = await this.projectService.findOne({
where: { id: invitation.projectId },
})

if (!project) {
await this.pendingInvitationService.delete(invitation.id)
throw new BadRequestException(
'The project this invitation was for no longer exists',
)
}
} else if (
invitation.type === PendingInvitationType.ORGANISATION_MEMBER &&
invitation.organisationId
) {
const organisation = await this.organisationService.findOne({
where: { id: invitation.organisationId },
})

if (!organisation) {
await this.pendingInvitationService.delete(invitation.id)
throw new BadRequestException(
'The organisation this invitation was for no longer exists',
)
}
}

const existingUser = await this.userService.findUser(body.email)

if (existingUser) {
throw new ConflictException(i18n.t('user.emailAlreadyUsed'))
}

if (body.checkIfLeaked) {
const isLeaked = await this.authService.checkIfLeaked(body.password)

if (isLeaked) {
throw new ConflictException(
'The provided password is leaked, please use another one',
)
}
}

if (body.email === body.password) {
throw new ConflictException(i18n.t('auth.passwordSameAsEmail'))
}

const newUser = await this.authService.createUnverifiedUser(
body.email,
body.password,
)

await this.userService.updateUser(newUser.id, {
hasCompletedOnboarding: true,
registeredViaInvitation: true,
})

await this.authService.redeemPendingInvitations(newUser)

await trackCustom(ip, headers['user-agent'], {
ev: 'SIGNUP',
meta: {
method: 'invitation',
},
})

const jwtTokens = await this.authService.generateJwtTokens(newUser.id, true)

const updatedUser = await this.userService.findUserById(newUser.id)

const [sharedProjects, organisationMemberships] = await Promise.all([
this.authService.getSharedProjectsForUser(newUser.id),
this.userService.getOrganisationsForUser(newUser.id),
])

updatedUser.sharedProjects = sharedProjects
updatedUser.organisationMemberships = organisationMemberships

Comment on lines +663 to +672
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential null reference: updatedUser could be null.

findUserById may return null (e.g., in case of a database issue), but lines 670-671 unconditionally assign properties to updatedUser. This would throw a runtime error.

🛡️ Proposed fix
     const updatedUser = await this.userService.findUserById(newUser.id)
 
+    if (!updatedUser) {
+      throw new ConflictException('Failed to retrieve user after registration')
+    }
+
     const [sharedProjects, organisationMemberships] = await Promise.all([
       this.authService.getSharedProjectsForUser(newUser.id),
       this.userService.getOrganisationsForUser(newUser.id),
     ])
📝 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
const updatedUser = await this.userService.findUserById(newUser.id)
const [sharedProjects, organisationMemberships] = await Promise.all([
this.authService.getSharedProjectsForUser(newUser.id),
this.userService.getOrganisationsForUser(newUser.id),
])
updatedUser.sharedProjects = sharedProjects
updatedUser.organisationMemberships = organisationMemberships
const updatedUser = await this.userService.findUserById(newUser.id)
if (!updatedUser) {
throw new ConflictException('Failed to retrieve user after registration')
}
const [sharedProjects, organisationMemberships] = await Promise.all([
this.authService.getSharedProjectsForUser(newUser.id),
this.userService.getOrganisationsForUser(newUser.id),
])
updatedUser.sharedProjects = sharedProjects
updatedUser.organisationMemberships = organisationMemberships
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/auth/auth.controller.ts` around lines 663 - 672, The
code calls userService.findUserById and then unconditionally assigns
updatedUser.sharedProjects and updatedUser.organisationMemberships, but
findUserById may return null; guard against a null updatedUser by checking its
existence before assigning (e.g., if (!updatedUser) { handle/error/throw }):
locate the updatedUser variable and the call to userService.findUserById, and
ensure you either throw or return an appropriate error/response when updatedUser
is null before using authService.getSharedProjectsForUser and
userService.getOrganisationsForUser results or move those calls after the null
check so you only assign properties on a non-null updatedUser.

return {
...jwtTokens,
user: this.userService.omitSensitiveData(updatedUser),
totalMonthlyEvents: 0,
}
}

// SSO section
@ApiOperation({ summary: 'Generate SSO authentication URL' })
@Post('sso/generate')
Expand Down
4 changes: 4 additions & 0 deletions backend/apps/cloud/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
JwtRefreshTokenStrategy,
} from './strategies'
import { Message } from '../integrations/telegram/entities/message.entity'
import { PendingInvitationModule } from '../pending-invitation/pending-invitation.module'
import { OrganisationModule } from '../organisation/organisation.module'

@Module({
imports: [
Expand All @@ -28,6 +30,8 @@ import { Message } from '../integrations/telegram/entities/message.entity'
ProjectModule,
forwardRef(() => TwoFactorAuthModule),
TypeOrmModule.forFeature([Message]),
PendingInvitationModule,
forwardRef(() => OrganisationModule),
],
controllers: [AuthController],
providers: [
Expand Down
Loading