From 50b81186cd4f4a9def31c6d969b76054a9fe90d5 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 9 Mar 2026 22:22:57 +0000 Subject: [PATCH 1/9] feat: Allow inviting people to projects / orgs who are not signed up on Swetrix --- backend/apps/cloud/src/app.module.ts | 2 + .../apps/cloud/src/auth/auth.controller.ts | 182 +++++++++++ backend/apps/cloud/src/auth/auth.module.ts | 4 + backend/apps/cloud/src/auth/dtos/index.ts | 1 + .../src/auth/dtos/register-invitation.dto.ts | 45 +++ .../organisation-invitation-unregistered.html | 14 + .../en/project-invitation-unregistered.html | 14 + backend/apps/cloud/src/mailer/letter.ts | 2 + .../apps/cloud/src/mailer/mailer.service.ts | 10 + .../organisation/organisation.controller.ts | 61 +++- .../src/organisation/organisation.module.ts | 2 + .../pending-invitation.entity.ts | 41 +++ .../pending-invitation.module.ts | 12 + .../pending-invitation.service.ts | 47 +++ .../cloud/src/project/project.controller.ts | 65 +++- .../apps/cloud/src/project/project.module.ts | 2 + .../cloud/src/user/entities/user.entity.ts | 3 + .../mysql/2026_03_09_pending_invitations.sql | 18 ++ .../how-to-invite-users-to-your-website.mdx | 16 +- web/app/api/api.server.ts | 84 +++++ web/app/lib/models/User.ts | 1 + .../pages/Auth/Signup/InvitationSignup.tsx | 301 ++++++++++++++++++ web/app/pages/Dashboard/Dashboard.tsx | 9 + web/app/routes/dashboard.tsx | 4 +- web/app/routes/login.tsx | 6 +- web/app/routes/signup.invitation.$id.tsx | 124 ++++++++ web/app/routes/subscribe.tsx | 6 +- web/app/utils/auth.ts | 1 + web/app/utils/routes.ts | 1 + web/public/locales/en.json | 11 + 30 files changed, 1069 insertions(+), 20 deletions(-) create mode 100644 backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts create mode 100644 backend/apps/cloud/src/common/templates/en/organisation-invitation-unregistered.html create mode 100644 backend/apps/cloud/src/common/templates/en/project-invitation-unregistered.html create mode 100644 backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts create mode 100644 backend/apps/cloud/src/pending-invitation/pending-invitation.module.ts create mode 100644 backend/apps/cloud/src/pending-invitation/pending-invitation.service.ts create mode 100644 backend/migrations/mysql/2026_03_09_pending_invitations.sql create mode 100644 web/app/pages/Auth/Signup/InvitationSignup.tsx create mode 100644 web/app/routes/signup.invitation.$id.tsx diff --git a/backend/apps/cloud/src/app.module.ts b/backend/apps/cloud/src/app.module.ts index fb04ce0d4..6068b9f0d 100644 --- a/backend/apps/cloud/src/app.module.ts +++ b/backend/apps/cloud/src/app.module.ts @@ -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(), @@ -90,6 +91,7 @@ const modules = [ OrganisationModule, RevenueModule, ToolsModule, + PendingInvitationModule, ] @Module({ diff --git a/backend/apps/cloud/src/auth/auth.controller.ts b/backend/apps/cloud/src/auth/auth.controller.ts index 2ce0e04b0..66ec516d7 100644 --- a/backend/apps/cloud/src/auth/auth.controller.ts +++ b/backend/apps/cloud/src/auth/auth.controller.ts @@ -8,6 +8,8 @@ import { Delete, Body, Ip, + Inject, + forwardRef, ConflictException, HttpCode, HttpStatus, @@ -15,6 +17,7 @@ import { Param, UnauthorizedException, BadRequestException, + NotFoundException, Headers, } from '@nestjs/common' import { @@ -50,6 +53,7 @@ import { SSOUnlinkDto, SSOProviders, SSOLinkWithPasswordDto, + RegisterInvitationRequestDto, } from './dtos' import { JwtAccessTokenGuard, @@ -58,6 +62,10 @@ 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 { ProjectShare } from '../project/entity/project-share.entity' +import { OrganisationService } from '../organisation/organisation.service' const OAUTH_RATE_LIMIT = 15 @@ -78,6 +86,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' }) @@ -434,6 +445,177 @@ 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 }, + }) + targetName = project?.name || '' + } else if ( + invitation.type === PendingInvitationType.ORGANISATION_MEMBER && + invitation.organisationId + ) { + const organisation = await this.organisationService.findOne({ + where: { id: invitation.organisationId }, + }) + targetName = organisation?.name || '' + } + + return { + id: invitation.id, + email: invitation.email, + type: invitation.type, + role: invitation.role, + inviterEmail: inviter?.email || '', + targetName, + } + } + + @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, + @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') + } + + 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, + }) + + const allPendingInvitations = + await this.pendingInvitationService.findByEmail(body.email) + + for (const pending of allPendingInvitations) { + if ( + pending.type === PendingInvitationType.PROJECT_SHARE && + pending.projectId + ) { + const project = await this.projectService.findOne({ + where: { id: pending.projectId }, + relations: ['share'], + }) + + if (project) { + const share = new ProjectShare() + share.role = pending.role as any + share.user = newUser + share.project = project + share.confirmed = true + + await this.projectService.createShare(share) + } + } else if ( + pending.type === PendingInvitationType.ORGANISATION_MEMBER && + pending.organisationId + ) { + const organisation = await this.organisationService.findOne({ + where: { id: pending.organisationId }, + }) + + if (organisation) { + await this.organisationService.createMembership({ + role: pending.role as any, + user: newUser, + organisation, + confirmed: true, + }) + } + } + } + + await this.pendingInvitationService.deleteAllByEmail(body.email) + + 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 + + return { + ...jwtTokens, + user: this.userService.omitSensitiveData(updatedUser), + totalMonthlyEvents: 0, + } + } + // SSO section @ApiOperation({ summary: 'Generate SSO authentication URL' }) @Post('sso/generate') diff --git a/backend/apps/cloud/src/auth/auth.module.ts b/backend/apps/cloud/src/auth/auth.module.ts index d7834331c..3ec6f6c34 100644 --- a/backend/apps/cloud/src/auth/auth.module.ts +++ b/backend/apps/cloud/src/auth/auth.module.ts @@ -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: [ @@ -28,6 +30,8 @@ import { Message } from '../integrations/telegram/entities/message.entity' ProjectModule, forwardRef(() => TwoFactorAuthModule), TypeOrmModule.forFeature([Message]), + PendingInvitationModule, + forwardRef(() => OrganisationModule), ], controllers: [AuthController], providers: [ diff --git a/backend/apps/cloud/src/auth/dtos/index.ts b/backend/apps/cloud/src/auth/dtos/index.ts index 4424503d2..7e1cd40ad 100644 --- a/backend/apps/cloud/src/auth/dtos/index.ts +++ b/backend/apps/cloud/src/auth/dtos/index.ts @@ -12,3 +12,4 @@ export * from './sso-get-jwt-by-hash.dto' export * from './sso-link.dto' export * from './sso-unlink.dto' export * from './sso-link-with-password.dto' +export * from './register-invitation.dto' diff --git a/backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts b/backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts new file mode 100644 index 000000000..fffaf03f2 --- /dev/null +++ b/backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger' +import { + IsEmail, + IsNotEmpty, + IsBoolean, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator' + +export class RegisterInvitationRequestDto { + @ApiProperty({ + description: 'Pending invitation ID', + }) + @IsNotEmpty() + @IsUUID('4') + public readonly pendingInvitationId: string + + @ApiProperty({ + description: 'User email', + example: 'yourusername@example.com', + maxLength: 254, + minLength: 6, + }) + @IsEmail({}, { message: 'Please enter the valid email.' }) + public readonly email: string + + @ApiProperty({ + description: 'User password', + example: '%d7*c4W45p', + maxLength: 72, + minLength: 8, + }) + @MaxLength(50, { message: 'Max length is $constraint1 characters' }) + @MinLength(8, { message: 'Min length is $constraint1 characters' }) + public readonly password: string + + @ApiProperty({ + description: 'Check if password is leaked', + example: true, + }) + @IsNotEmpty({ message: 'This field is required.' }) + @IsBoolean({ message: 'Please enter the valid value (true or false).' }) + public readonly checkIfLeaked: boolean +} diff --git a/backend/apps/cloud/src/common/templates/en/organisation-invitation-unregistered.html b/backend/apps/cloud/src/common/templates/en/organisation-invitation-unregistered.html new file mode 100644 index 000000000..fe04b1705 --- /dev/null +++ b/backend/apps/cloud/src/common/templates/en/organisation-invitation-unregistered.html @@ -0,0 +1,14 @@ +{{email}} has invited you to join the {{name}} +organisation with the role {{role}} on Swetrix. +
+You don't have a Swetrix account yet, but you can create one to accept the +invitation. +
+Click the button below to create your account and join the organisation. +
+Create account & accept invitation +
+If you are having trouble with the button above, copy and paste the URL below +directly into your web browser: +
+{{url}} diff --git a/backend/apps/cloud/src/common/templates/en/project-invitation-unregistered.html b/backend/apps/cloud/src/common/templates/en/project-invitation-unregistered.html new file mode 100644 index 000000000..329c0a5de --- /dev/null +++ b/backend/apps/cloud/src/common/templates/en/project-invitation-unregistered.html @@ -0,0 +1,14 @@ +{{email}} has invited you to the {{name}} website +analytics dashboard with the role {{role}} on Swetrix. +
+You don't have a Swetrix account yet, but you can create one to accept the +invitation. +
+Click the button below to create your account and join the project. +
+Create account & accept invitation +
+If you are having trouble with the button above, copy and paste the URL below +directly into your web browser: +
+{{url}} diff --git a/backend/apps/cloud/src/mailer/letter.ts b/backend/apps/cloud/src/mailer/letter.ts index 374bb474a..d7c8cafe1 100644 --- a/backend/apps/cloud/src/mailer/letter.ts +++ b/backend/apps/cloud/src/mailer/letter.ts @@ -19,6 +19,8 @@ export enum LetterTemplate { DashboardLockedExceedingLimits = 'dashboard-locked-exceeding-limits', DashboardLockedPaymentFailure = 'dashboard-locked-payment-failure', OrganisationInvitation = 'organisation-invitation', + ProjectInvitationUnregistered = 'project-invitation-unregistered', + OrganisationInvitationUnregistered = 'organisation-invitation-unregistered', SocialIdentityLinked = 'social-identity-linked', NoEventsAfterSignup = 'no-events-after-signup', } diff --git a/backend/apps/cloud/src/mailer/mailer.service.ts b/backend/apps/cloud/src/mailer/mailer.service.ts index f05d6f9b1..fd8824528 100644 --- a/backend/apps/cloud/src/mailer/mailer.service.ts +++ b/backend/apps/cloud/src/mailer/mailer.service.ts @@ -61,6 +61,16 @@ const metaInfoJson = { en: () => 'You have been invited to join the organisation', }, }, + [LetterTemplate.ProjectInvitationUnregistered]: { + subject: { + en: () => 'You have been invited to join a project on Swetrix', + }, + }, + [LetterTemplate.OrganisationInvitationUnregistered]: { + subject: { + en: () => 'You have been invited to join an organisation on Swetrix', + }, + }, [LetterTemplate.TwoFAOn]: { subject: { en: () => '2FA has been enabled on your Swetrix account', diff --git a/backend/apps/cloud/src/organisation/organisation.controller.ts b/backend/apps/cloud/src/organisation/organisation.controller.ts index a32943b4f..253ea07a2 100644 --- a/backend/apps/cloud/src/organisation/organisation.controller.ts +++ b/backend/apps/cloud/src/organisation/organisation.controller.ts @@ -41,6 +41,8 @@ import { Project } from '../project/entity' import { isDevelopment, PRODUCTION_ORIGIN } from '../common/constants' import { checkRateLimit, getIPFromHeaders } from '../common/utils' import { trackCustom } from '../common/analytics' +import { PendingInvitationService } from '../pending-invitation/pending-invitation.service' +import { PendingInvitationType } from '../pending-invitation/pending-invitation.entity' const ORGANISATION_INVITE_EXPIRE = 7 * 24 // 7 days in hours @@ -53,6 +55,7 @@ export class OrganisationController { private readonly actionTokensService: ActionTokensService, private readonly logger: AppLoggerService, private readonly projectService: ProjectService, + private readonly pendingInvitationService: PendingInvitationService, ) {} @ApiBearerAuth() @@ -200,21 +203,61 @@ export class OrganisationController { }) if (!invitee) { - throw new NotFoundException( - `User with email ${inviteDTO.email} is not registered`, - ) + const existingPending = + await this.pendingInvitationService.findByEmailAndOrganisation( + inviteDTO.email, + orgId, + ) + + if (existingPending) { + throw new BadRequestException( + `An invitation has already been sent to ${inviteDTO.email}`, + ) + } + + try { + const pendingInvitation = await this.pendingInvitationService.create({ + email: inviteDTO.email, + type: PendingInvitationType.ORGANISATION_MEMBER, + organisationId: orgId, + role: inviteDTO.role, + inviterId: userId, + }) + + const origin = + isDevelopment && typeof headers?.origin === 'string' + ? headers.origin + : PRODUCTION_ORIGIN + const url = `${origin}/signup/invitation/${pendingInvitation.id}?email=${encodeURIComponent(inviteDTO.email)}` + + await this.mailerService.sendEmail( + inviteDTO.email, + LetterTemplate.OrganisationInvitationUnregistered, + { + url, + email: user.email, + name: organisation.name, + role: inviteDTO.role, + }, + ) + + return await this.organisationService.findOne({ + where: { id: orgId }, + relations: ['members', 'members.user'], + }) + } catch (reason) { + this.logger.error( + { orgId: organisation?.id, email: inviteDTO.email, reason }, + 'Could not create pending invitation for organisation', + ) + throw new BadRequestException('Failed to invite member to organisation') + } } if (invitee.id === user.id) { throw new BadRequestException('You cannot invite yourself') } - // if (!this.userService.isPaidTier(invitee)) { - // throw new BadRequestException( - // 'You must be a paid tier subscriber to use this feature.', - // ) - // } - const isAlreadyMember = !_isEmpty( _find(organisation.members, (member) => member.user?.id === invitee.id), ) diff --git a/backend/apps/cloud/src/organisation/organisation.module.ts b/backend/apps/cloud/src/organisation/organisation.module.ts index a67f76ce7..ce0645f91 100644 --- a/backend/apps/cloud/src/organisation/organisation.module.ts +++ b/backend/apps/cloud/src/organisation/organisation.module.ts @@ -9,6 +9,7 @@ import { MailerModule } from '../mailer/mailer.module' import { ActionTokensModule } from '../action-tokens/action-tokens.module' import { AppLoggerModule } from '../logger/logger.module' import { ProjectModule } from '../project/project.module' +import { PendingInvitationModule } from '../pending-invitation/pending-invitation.module' @Module({ imports: [ @@ -18,6 +19,7 @@ import { ProjectModule } from '../project/project.module' MailerModule, ActionTokensModule, AppLoggerModule, + PendingInvitationModule, ], controllers: [OrganisationController], providers: [OrganisationService], diff --git a/backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts b/backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts new file mode 100644 index 000000000..b93336bcb --- /dev/null +++ b/backend/apps/cloud/src/pending-invitation/pending-invitation.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, +} from 'typeorm' + +export enum PendingInvitationType { + PROJECT_SHARE = 'project_share', + ORGANISATION_MEMBER = 'organisation_member', +} + +@Entity() +export class PendingInvitation { + @PrimaryGeneratedColumn('uuid') + id: string + + @Column('varchar', { length: 254 }) + email: string + + @Column({ + type: 'enum', + enum: PendingInvitationType, + }) + type: PendingInvitationType + + @Column('varchar', { nullable: true }) + projectId: string | null + + @Column('varchar', { nullable: true }) + organisationId: string | null + + @Column('varchar') + role: string + + @Column('varchar') + inviterId: string + + @CreateDateColumn() + created: Date +} diff --git a/backend/apps/cloud/src/pending-invitation/pending-invitation.module.ts b/backend/apps/cloud/src/pending-invitation/pending-invitation.module.ts new file mode 100644 index 000000000..05f098606 --- /dev/null +++ b/backend/apps/cloud/src/pending-invitation/pending-invitation.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' + +import { PendingInvitation } from './pending-invitation.entity' +import { PendingInvitationService } from './pending-invitation.service' + +@Module({ + imports: [TypeOrmModule.forFeature([PendingInvitation])], + providers: [PendingInvitationService], + exports: [PendingInvitationService], +}) +export class PendingInvitationModule {} diff --git a/backend/apps/cloud/src/pending-invitation/pending-invitation.service.ts b/backend/apps/cloud/src/pending-invitation/pending-invitation.service.ts new file mode 100644 index 000000000..610bcd543 --- /dev/null +++ b/backend/apps/cloud/src/pending-invitation/pending-invitation.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common' +import { InjectRepository } from '@nestjs/typeorm' +import { Repository } from 'typeorm' + +import { PendingInvitation } from './pending-invitation.entity' + +@Injectable() +export class PendingInvitationService { + constructor( + @InjectRepository(PendingInvitation) + private readonly repository: Repository, + ) {} + + async create(data: Partial): Promise { + return this.repository.save(data) + } + + async findById(id: string): Promise { + return this.repository.findOne({ where: { id } }) + } + + async findByEmail(email: string): Promise { + return this.repository.find({ where: { email } }) + } + + async findByEmailAndProject( + email: string, + projectId: string, + ): Promise { + return this.repository.findOne({ where: { email, projectId } }) + } + + async findByEmailAndOrganisation( + email: string, + organisationId: string, + ): Promise { + return this.repository.findOne({ where: { email, organisationId } }) + } + + async delete(id: string): Promise { + await this.repository.delete(id) + } + + async deleteAllByEmail(email: string): Promise { + await this.repository.delete({ email }) + } +} diff --git a/backend/apps/cloud/src/project/project.controller.ts b/backend/apps/cloud/src/project/project.controller.ts index 9a0d18b60..8042631e6 100644 --- a/backend/apps/cloud/src/project/project.controller.ts +++ b/backend/apps/cloud/src/project/project.controller.ts @@ -111,6 +111,9 @@ import { OrganisationService } from '../organisation/organisation.service' import { Organisation } from '../organisation/entity/organisation.entity' import { ProjectOrganisationDto } from './dto/project-organisation.dto' import { trackCustom } from '../common/analytics' +import { PendingInvitationService } from '../pending-invitation/pending-invitation.service' +import { PendingInvitationType } from '../pending-invitation/pending-invitation.entity' +import { PlanCode } from '../user/entities/user.entity' const PROJECTS_MAXIMUM = 50 @@ -133,6 +136,7 @@ export class ProjectController { private readonly mailerService: MailerService, private readonly projectsViewsRepository: ProjectsViewsRepository, private readonly organisationService: OrganisationService, + private readonly pendingInvitationService: PendingInvitationService, ) {} @ApiBearerAuth() @@ -330,6 +334,17 @@ export class ProjectController { ) } + if ( + initiatingUser.planCode === PlanCode.none && + initiatingUser.hasCompletedOnboarding && + !projectDTO.organisationId + ) { + throw new HttpException( + 'You need an active subscription to create personal projects. Please start a free trial or subscribe to a plan.', + HttpStatus.PAYMENT_REQUIRED, + ) + } + if (user.isAccountBillingSuspended) { throw new HttpException( 'This account is currently suspended, this is because of a billing issue. Please resolve the issue to continue.', @@ -1030,9 +1045,53 @@ export class ProjectController { }) if (!invitee) { - throw new NotFoundException( - `User with email ${shareDTO.email} is not registered on Swetrix`, - ) + const existingPending = + await this.pendingInvitationService.findByEmailAndProject( + shareDTO.email, + pid, + ) + + if (existingPending) { + throw new BadRequestException( + `An invitation has already been sent to ${shareDTO.email}`, + ) + } + + try { + const pendingInvitation = await this.pendingInvitationService.create({ + email: shareDTO.email, + type: PendingInvitationType.PROJECT_SHARE, + projectId: pid, + role: shareDTO.role, + inviterId: userId, + }) + + const origin = isDevelopment ? headers.origin : PRODUCTION_ORIGIN + const url = `${origin}/signup/invitation/${pendingInvitation.id}?email=${encodeURIComponent(shareDTO.email)}` + + await this.mailerService.sendEmail( + shareDTO.email, + LetterTemplate.ProjectInvitationUnregistered, + { + url, + email: user.email, + name: project.name, + role: shareDTO.role, + }, + ) + + const updatedProject = await this.projectService.findOne({ + where: { id: pid }, + relations: ['share', 'share.user'], + }) + + return processProjectUser(updatedProject) + } catch (reason) { + console.error( + `[ERROR] Could not create pending invitation for project (pid: ${project.id}, email: ${shareDTO.email}): ${reason}`, + ) + throw new BadRequestException(reason) + } } if (invitee.id === user.id) { diff --git a/backend/apps/cloud/src/project/project.module.ts b/backend/apps/cloud/src/project/project.module.ts index 7647e850a..b4fb53d18 100644 --- a/backend/apps/cloud/src/project/project.module.ts +++ b/backend/apps/cloud/src/project/project.module.ts @@ -21,6 +21,7 @@ import { ProjectsViewsRepository } from './repositories/projects-views.repositor import { ProjectViewEntity } from './entity/project-view.entity' import { ProjectViewCustomEventEntity } from './entity/project-view-custom-event.entity' import { OrganisationModule } from '../organisation/organisation.module' +import { PendingInvitationModule } from '../pending-invitation/pending-invitation.module' @Module({ imports: [ @@ -39,6 +40,7 @@ import { OrganisationModule } from '../organisation/organisation.module' AppLoggerModule, ActionTokensModule, MailerModule, + PendingInvitationModule, ], providers: [ProjectService, ProjectsViewsRepository, GSCService], exports: [ProjectService, ProjectsViewsRepository, GSCService], diff --git a/backend/apps/cloud/src/user/entities/user.entity.ts b/backend/apps/cloud/src/user/entities/user.entity.ts index 22d7d0185..4d3a4d9d1 100644 --- a/backend/apps/cloud/src/user/entities/user.entity.ts +++ b/backend/apps/cloud/src/user/entities/user.entity.ts @@ -408,6 +408,9 @@ export class User { @Column({ default: false }) hasCompletedOnboarding: boolean + @Column({ default: false }) + registeredViaInvitation: boolean + // Google SSO @Column({ type: 'varchar', diff --git a/backend/migrations/mysql/2026_03_09_pending_invitations.sql b/backend/migrations/mysql/2026_03_09_pending_invitations.sql new file mode 100644 index 000000000..388f15bee --- /dev/null +++ b/backend/migrations/mysql/2026_03_09_pending_invitations.sql @@ -0,0 +1,18 @@ +-- Pending invitations table for inviting unregistered users +CREATE TABLE IF NOT EXISTS `pending_invitation` ( + `id` varchar(36) NOT NULL, + `email` varchar(254) NOT NULL, + `type` enum('project_share', 'organisation_member') NOT NULL, + `projectId` varchar(255) DEFAULT NULL, + `organisationId` varchar(255) DEFAULT NULL, + `role` varchar(255) NOT NULL, + `inviterId` varchar(255) NOT NULL, + `created` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `IDX_pending_invitation_email` (`email`), + KEY `IDX_pending_invitation_project` (`projectId`), + KEY `IDX_pending_invitation_organisation` (`organisationId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Add registeredViaInvitation flag to user table +ALTER TABLE `user` ADD COLUMN `registeredViaInvitation` tinyint(1) NOT NULL DEFAULT 0; diff --git a/docs/content/docs/sitesettings/how-to-invite-users-to-your-website.mdx b/docs/content/docs/sitesettings/how-to-invite-users-to-your-website.mdx index 430dd463e..f11f23d70 100644 --- a/docs/content/docs/sitesettings/how-to-invite-users-to-your-website.mdx +++ b/docs/content/docs/sitesettings/how-to-invite-users-to-your-website.mdx @@ -5,7 +5,7 @@ slug: /how-to-invite-users-to-your-website Swetrix allows you to invite other people to your website. This is useful if you want to give access to your team, clients or other people without sharing your login credentials. -You can invite any person to your website, but they need to have a Swetrix account. At the same time, they don't necessarily need to have a paid subscription to be invited to and access your website. +You can invite any person to your website using their email address. If they already have a Swetrix account, they'll receive an invitation notification in their dashboard and via email. If they don't have an account yet, they'll receive an email with a link to create one — no paid subscription or credit card is required for invited users. If you want to share your dashboard without requiring the other person to have a Swetrix account, you can password-protect your dashboard and share the password with them. @@ -16,13 +16,21 @@ If you want to share your dashboard without requiring the other person to have a 1. Go to your [site settings](/how-to-access-site-settings) page. 2. Scroll down to the "People" section and click the "Invite a user" button. -3. Enter the email address of the person you want to invite. Please note that the person needs to have a Swetrix account with the same email address. +3. Enter the email address of the person you want to invite. 4. Select a role for the person you're inviting. You can choose between "Admin" and "Viewer" roles. - **Admin**: Admins have full access to your website. They can view and edit all settings, add and remove other users, and view all analytics data. - **Viewer**: Viewers can only view your website's analytics data. They can't edit any settings or invite other users. -5. Click the "Invite" button to send the invitation. The person will receive an email with a link to accept the invitation. Once they accept it, they will be able to access your website. We'll also display a notification in their Dashboard page to let them know they've been invited to your website and they can accept the invitation from there as well. +5. Click the "Invite" button to send the invitation. + +### If the invitee already has a Swetrix account + +They will receive an email with a link to accept the invitation. Once they accept it, they will be able to access your website. We'll also display a notification in their Dashboard page to let them know they've been invited to your website and they can accept the invitation from there as well. + +### If the invitee does not have a Swetrix account + +They will receive an email with a link to create their account. After signing up through that link, the invitation is automatically accepted and they'll have immediate access to your website's analytics dashboard. These users do not need to start a trial or enter a credit card. Invite user @@ -32,6 +40,8 @@ The owner of the website is responsible for billing and needs to have a paid sub However, if you transfer ownership of your website to someone else, they will become responsible for billing. +If an invited user wants to create their own personal projects later on, they will need to start a free trial and subscribe to a plan. + ## How to modify user permissions You can modify the permissions of any user at any time. To do this, go to your [site settings](/how-to-access-site-settings) page and scroll down to the "People" section. Here you can see a list of all users who have access to your website. diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 632cd1cc8..69a269511 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -442,6 +442,90 @@ export async function registerUser( } } +export async function getInvitationDetails( + request: Request, + invitationId: string, +): Promise<{ + success: boolean + data?: { + id: string + email: string + type: string + role: string + inviterEmail: string + targetName: string + } + error?: string +}> { + const result = await serverFetch<{ + id: string + email: string + type: string + role: string + inviterEmail: string + targetName: string + }>(request, `v1/auth/invitation/${invitationId}`, { + skipAuth: true, + }) + + if (result.error || !result.data) { + return { + success: false, + error: Array.isArray(result.error) + ? result.error[0] + : result.error || 'Invitation not found', + } + } + + return { + success: true, + data: result.data, + } +} + +export async function registerViaInvitation( + request: Request, + data: { + pendingInvitationId: string + email: string + password: string + checkIfLeaked: boolean + }, +): Promise<{ + success: boolean + data?: Auth + error?: string | string[] + cookies: string[] +}> { + const result = await serverFetch( + request, + 'v1/auth/register/invitation', + { + method: 'POST', + body: data, + skipAuth: true, + }, + ) + + if (result.error || !result.data) { + return { + success: false, + error: result.error || 'Registration failed', + cookies: [], + } + } + + const { accessToken, refreshToken } = result.data + + const cookies = createAuthCookies({ accessToken, refreshToken }, true) + + return { + success: true, + data: result.data, + cookies, + } +} + export async function logoutUser( request: Request, options: { logoutAll?: boolean } = {}, diff --git a/web/app/lib/models/User.ts b/web/app/lib/models/User.ts index b72f4558e..999c0b29c 100644 --- a/web/app/lib/models/User.ts +++ b/web/app/lib/models/User.ts @@ -77,4 +77,5 @@ export interface User { organisationMemberships: OrganisationMembership[] onboardingStep: OnboardingStep hasCompletedOnboarding: boolean + registeredViaInvitation: boolean } diff --git a/web/app/pages/Auth/Signup/InvitationSignup.tsx b/web/app/pages/Auth/Signup/InvitationSignup.tsx new file mode 100644 index 000000000..83d664f8f --- /dev/null +++ b/web/app/pages/Auth/Signup/InvitationSignup.tsx @@ -0,0 +1,301 @@ +import { ArrowRightIcon, EnvelopeIcon } from '@phosphor-icons/react' +import React, { useState, useEffect, memo } from 'react' +import { useTranslation, Trans } from 'react-i18next' +import { + Link, + Form, + useActionData, + useNavigation, + useLoaderData, +} from 'react-router' +import { toast } from 'sonner' + +import { HAVE_I_BEEN_PWNED_URL } from '~/lib/constants' +import type { + InvitationSignupLoaderData, + InvitationSignupActionData, +} from '~/routes/signup.invitation.$id' +import Button from '~/ui/Button' +import Checkbox from '~/ui/Checkbox' +import Input from '~/ui/Input' +import { Text } from '~/ui/Text' +import Tooltip from '~/ui/Tooltip' +import routes from '~/utils/routes' +import { MIN_PASSWORD_CHARS } from '~/utils/validator' + +const InvitationSignup = () => { + const { t } = useTranslation('common') + const navigation = useNavigation() + const actionData = useActionData() + const loaderData = useLoaderData() + + const [tos, setTos] = useState(false) + const [checkIfLeaked, setCheckIfLeaked] = useState(true) + const [clearedErrors, setClearedErrors] = useState>(new Set()) + + const isFormSubmitting = navigation.state === 'submitting' + const invitation = loaderData?.invitation + + useEffect(() => { + if (actionData?.error && !actionData?.fieldErrors) { + const errorMessage = Array.isArray(actionData.error) + ? actionData.error[0] + : actionData.error + toast.error(errorMessage) + } + }, [actionData?.error, actionData?.fieldErrors, actionData?.timestamp]) + + const clearFieldError = (fieldName: string) => { + if ( + actionData?.fieldErrors?.[ + fieldName as keyof typeof actionData.fieldErrors + ] + ) { + setClearedErrors((prev) => new Set(prev).add(fieldName)) + } + } + + const getFieldError = (fieldName: string) => { + if (clearedErrors.has(fieldName)) { + return undefined + } + return actionData?.fieldErrors?.[ + fieldName as keyof typeof actionData.fieldErrors + ] + } + + const handleFormSubmit = () => { + setClearedErrors(new Set()) + } + + if (loaderData?.error || !invitation) { + return ( +
+
+ + {t('common.error')} + + + {loaderData?.error || t('auth.invitation.invalidLink')} + + + {t('auth.signup.createAnAccount')} + +
+
+ ) + } + + const typeLabel = + invitation.type === 'project_share' + ? t('auth.invitation.project') + : t('auth.invitation.organisation') + + return ( +
+
+
+
+ + {t('auth.signup.createAnAccount')} + +
+ +
+
+
+ +
+
+ + + ), + }} + /> + + + , + }} + /> + +
+
+
+ +
+ + clearFieldError('password')} + /> + + + + { + setTos(checked) + clearFieldError('tos') + }} + disabled={isFormSubmitting} + label={ + + + ), + pp: ( + + ), + }} + /> + + } + classes={{ + hint: '!text-red-600 dark:!text-red-500', + }} + hint={getFieldError('tos')} + /> + +
+ {t('auth.common.checkLeakedPassword')} + } + /> + + ), + }} + values={{ + database: 'haveibeenpwned.com', + }} + /> + } + /> +
+ + + + + + + ), + }} + /> + +
+
+ +
+
+
+ +
+
+ +
+ + {t('auth.invitation.youreInvited')} + + + , + }} + /> + +
+
+
+ ) +} + +export default memo(InvitationSignup) diff --git a/web/app/pages/Dashboard/Dashboard.tsx b/web/app/pages/Dashboard/Dashboard.tsx index 4bd63285c..078df7c97 100644 --- a/web/app/pages/Dashboard/Dashboard.tsx +++ b/web/app/pages/Dashboard/Dashboard.tsx @@ -245,6 +245,15 @@ const Dashboard = () => { return } + if ( + !isSelfhosted && + user?.planCode === 'none' && + !newProjectOrganisationId + ) { + toast.error(t('project.settings.subscriptionRequired')) + return + } + const formData = new FormData() formData.set('intent', 'create-project') formData.set('name', newProjectName || DEFAULT_PROJECT_NAME) diff --git a/web/app/routes/dashboard.tsx b/web/app/routes/dashboard.tsx index 0af61c5bc..ee2b3d436 100644 --- a/web/app/routes/dashboard.tsx +++ b/web/app/routes/dashboard.tsx @@ -210,11 +210,11 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect('/onboarding') } - // If user is not subscribed (trial or real sub), and they're not past subscription (none tier, but have blocked dashboard), redirect to checkout if ( !isSelfhosted && user?.planCode === 'none' && - !user?.dashboardBlockReason + !user?.dashboardBlockReason && + !user?.registeredViaInvitation ) { if (authCookies.length > 0) { return redirect('/subscribe', { diff --git a/web/app/routes/login.tsx b/web/app/routes/login.tsx index 0d52876bc..a78937be5 100644 --- a/web/app/routes/login.tsx +++ b/web/app/routes/login.tsx @@ -83,7 +83,11 @@ export async function action({ request }: ActionFunctionArgs) { const result = await serverFetch<{ accessToken: string refreshToken: string - user: { hasCompletedOnboarding: boolean; planCode?: string } + user: { + hasCompletedOnboarding: boolean + planCode?: string + registeredViaInvitation?: boolean + } }>(request, '2fa/authenticate', { method: 'POST', body: { twoFactorAuthenticationCode }, diff --git a/web/app/routes/signup.invitation.$id.tsx b/web/app/routes/signup.invitation.$id.tsx new file mode 100644 index 000000000..f46eac496 --- /dev/null +++ b/web/app/routes/signup.invitation.$id.tsx @@ -0,0 +1,124 @@ +import type { + ActionFunctionArgs, + HeadersFunction, + LoaderFunctionArgs, +} from 'react-router' +import { redirect, data } from 'react-router' + +import { + getAuthenticatedUser, + getInvitationDetails, + registerViaInvitation, +} from '~/api/api.server' +import InvitationSignup from '~/pages/Auth/Signup/InvitationSignup' +import { createHeadersWithCookies } from '~/utils/session.server' + +export const headers: HeadersFunction = ({ parentHeaders }) => { + parentHeaders.set('X-Frame-Options', 'DENY') + return parentHeaders +} + +export interface InvitationDetails { + id: string + email: string + type: string + role: string + inviterEmail: string + targetName: string +} + +export interface InvitationSignupLoaderData { + invitation?: InvitationDetails + error?: string +} + +export interface InvitationSignupActionData { + error?: string | string[] + fieldErrors?: { + email?: string + password?: string + tos?: string + } + timestamp?: number +} + +export async function loader({ + request, + params, +}: LoaderFunctionArgs): Promise { + const authResult = await getAuthenticatedUser(request) + + if (authResult) { + const user = authResult.user.user + + if (!user.hasCompletedOnboarding) { + throw redirect('/onboarding') + } + throw redirect('/dashboard') + } + + const { id } = params + + if (!id) { + return { error: 'Invalid invitation link' } + } + + const result = await getInvitationDetails(request, id) + + if (!result.success || !result.data) { + return { error: result.error || 'Invitation not found or has expired' } + } + + return { invitation: result.data } +} + +export async function action({ request, params }: ActionFunctionArgs) { + const { id } = params + const formData = await request.formData() + + const email = formData.get('email')?.toString() || '' + const password = formData.get('password')?.toString() || '' + const tos = formData.get('tos') === 'true' + const checkIfLeaked = formData.get('checkIfLeaked') === 'true' + + const fieldErrors: InvitationSignupActionData['fieldErrors'] = {} + + if (!email || !email.includes('@')) { + fieldErrors.email = 'Please enter a valid email address' + } + + if (!password || password.length < 8) { + fieldErrors.password = 'Password must be at least 8 characters' + } + + if (password.length > 50) { + fieldErrors.password = 'Password must be at most 50 characters' + } + + if (!tos) { + fieldErrors.tos = 'You must accept the Terms of Service' + } + + if (fieldErrors.email || fieldErrors.password || fieldErrors.tos) { + return data({ fieldErrors, timestamp: Date.now() }, { status: 400 }) + } + + const result = await registerViaInvitation(request, { + pendingInvitationId: id!, + email, + password, + checkIfLeaked, + }) + + if (!result.success) { + return data({ error: result.error, timestamp: Date.now() }, { status: 400 }) + } + + return redirect('/dashboard', { + headers: createHeadersWithCookies(result.cookies), + }) +} + +export default function InvitationSignupPage() { + return +} diff --git a/web/app/routes/subscribe.tsx b/web/app/routes/subscribe.tsx index 875eeb104..61ab9b3f6 100644 --- a/web/app/routes/subscribe.tsx +++ b/web/app/routes/subscribe.tsx @@ -55,8 +55,10 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect('/onboarding') } - // If the user already has an active subscription or legacy trial, skip checkout - if (user?.planCode && user.planCode !== 'none') { + if ( + (user?.planCode && user.planCode !== 'none') || + user?.registeredViaInvitation + ) { if (cookies.length > 0) { return redirect('/dashboard', { headers: createHeadersWithCookies(cookies), diff --git a/web/app/utils/auth.ts b/web/app/utils/auth.ts index bfee99c38..8ce00f33d 100644 --- a/web/app/utils/auth.ts +++ b/web/app/utils/auth.ts @@ -12,6 +12,7 @@ import routes from './routes' export const decidePostAuthRedirect = (user: { hasCompletedOnboarding: boolean planCode?: string + registeredViaInvitation?: boolean }): string => { if (!user.hasCompletedOnboarding) { return routes.onboarding diff --git a/web/app/utils/routes.ts b/web/app/utils/routes.ts index cdf21e1d5..325d34f61 100644 --- a/web/app/utils/routes.ts +++ b/web/app/utils/routes.ts @@ -1,6 +1,7 @@ const routes = Object.freeze({ signin: '/login', signup: '/signup', + signup_invitation: '/signup/invitation/:id', performance: '/performance', errorTracking: '/error-tracking', captchaLanding: '/captcha', diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 54014bf30..dd9fe3a05 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -726,6 +726,16 @@ "intuitiveDesc": "Easy to use, no need to be a data scientist." } }, + "invitation": { + "invalidLink": "This invitation link is invalid or has expired.", + "invitedToJoin": "You've been invited to join {{targetName}}", + "invitedByAs": "{{inviterEmail}} invited you as {{role}} to this {{type}}. Create your account to get started.", + "createAndJoin": "Create account & join {{type}}", + "youreInvited": "You're invited", + "sidebarDesc": "Create your free account to access the analytics for {{targetName}}. No credit card required.", + "project": "project", + "organisation": "organisation" + }, "verification": { "success": "Your email has been successfully verified!", "continueToOnboarding": "Continue to onboarding" @@ -1771,6 +1781,7 @@ "pxCharsError": "Project name cannot be longer than {{amount}} characters.", "oxCharsError": "A list of allowed origins has to be smaller than {{amount}} symbols.", "noNameError": "Please enter a project name.", + "subscriptionRequired": "You need an active subscription to create personal projects. Please start a free trial or subscribe to a plan.", "create": "Create a new project", "settings": "Settings of", "name": "Project name", From 7a6fa4c733aadc722b8d55e2a82f86cb4ffd3513 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 9 Mar 2026 22:58:43 +0000 Subject: [PATCH 2/9] fix: cache revalidation & invitation signup form --- .../apps/cloud/src/auth/auth.controller.ts | 6 +- .../cloud/src/project/project.controller.ts | 2 +- .../pages/Auth/Signup/InvitationSignup.tsx | 339 ++++++++---------- web/app/pages/Dashboard/AddProject.tsx | 6 +- ...ion.$id.tsx => signup_.invitation.$id.tsx} | 17 + web/app/ui/Input.tsx | 7 +- web/app/ui/Text.tsx | 2 +- web/public/locales/en.json | 4 +- 8 files changed, 183 insertions(+), 200 deletions(-) rename web/app/routes/{signup.invitation.$id.tsx => signup_.invitation.$id.tsx} (85%) diff --git a/backend/apps/cloud/src/auth/auth.controller.ts b/backend/apps/cloud/src/auth/auth.controller.ts index 66ec516d7..d0ac49762 100644 --- a/backend/apps/cloud/src/auth/auth.controller.ts +++ b/backend/apps/cloud/src/auth/auth.controller.ts @@ -60,7 +60,7 @@ import { JwtRefreshTokenGuard, AuthenticationGuard, } from './guards' -import { ProjectService } from '../project/project.service' +import { ProjectService, deleteProjectRedis } 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' @@ -568,6 +568,7 @@ export class AuthController { share.confirmed = true await this.projectService.createShare(share) + await deleteProjectRedis(project.id) } } else if ( pending.type === PendingInvitationType.ORGANISATION_MEMBER && @@ -584,6 +585,9 @@ export class AuthController { organisation, confirmed: true, }) + await this.organisationService.deleteOrganisationProjectsFromRedis( + organisation.id, + ) } } } diff --git a/backend/apps/cloud/src/project/project.controller.ts b/backend/apps/cloud/src/project/project.controller.ts index 8042631e6..13836eb03 100644 --- a/backend/apps/cloud/src/project/project.controller.ts +++ b/backend/apps/cloud/src/project/project.controller.ts @@ -340,7 +340,7 @@ export class ProjectController { !projectDTO.organisationId ) { throw new HttpException( - 'You need an active subscription to create personal projects. Please start a free trial or subscribe to a plan.', + 'You need an active subscription to create personal projects. Please start a free trial or subscribe to a plan in your account settings.', HttpStatus.PAYMENT_REQUIRED, ) } diff --git a/web/app/pages/Auth/Signup/InvitationSignup.tsx b/web/app/pages/Auth/Signup/InvitationSignup.tsx index 83d664f8f..fa63b03d7 100644 --- a/web/app/pages/Auth/Signup/InvitationSignup.tsx +++ b/web/app/pages/Auth/Signup/InvitationSignup.tsx @@ -1,4 +1,4 @@ -import { ArrowRightIcon, EnvelopeIcon } from '@phosphor-icons/react' +import { ArrowRightIcon } from '@phosphor-icons/react' import React, { useState, useEffect, memo } from 'react' import { useTranslation, Trans } from 'react-i18next' import { @@ -14,7 +14,8 @@ import { HAVE_I_BEEN_PWNED_URL } from '~/lib/constants' import type { InvitationSignupLoaderData, InvitationSignupActionData, -} from '~/routes/signup.invitation.$id' +} from '~/routes/signup_.invitation.$id' +import Alert from '~/ui/Alert' import Button from '~/ui/Button' import Checkbox from '~/ui/Checkbox' import Input from '~/ui/Input' @@ -95,204 +96,164 @@ const InvitationSignup = () => { : t('auth.invitation.organisation') return ( -
-
-
-
- - {t('auth.signup.createAnAccount')} - -
- -
-
-
- -
-
- - - ), - }} - /> - - - , - }} - /> - -
-
-
+
+
+
+ + {t('auth.signup.createAnAccount')} + +
-
- - clearFieldError('password')} - /> - - +

+ , + }} /> +

+ , + }} + /> + + + + + clearFieldError('password')} + /> + + + + { + setTos(checked) + clearFieldError('tos') + }} + disabled={isFormSubmitting} + label={ + + + ), + pp: ( + + ), + }} + /> + + } + classes={{ + hint: '!text-red-600 dark:!text-red-500', + }} + hint={getFieldError('tos')} + /> +
{ - setTos(checked) - clearFieldError('tos') - }} + checked={checkIfLeaked} + onChange={setCheckIfLeaked} disabled={isFormSubmitting} label={ - - - ), - pp: ( - - ), - }} - /> - + {t('auth.common.checkLeakedPassword')} } - classes={{ - hint: '!text-red-600 dark:!text-red-500', - }} - hint={getFieldError('tos')} /> - -
- {t('auth.common.checkLeakedPassword')} - } - /> - - ), - }} - values={{ - database: 'haveibeenpwned.com', - }} - /> - } - /> -
- - - - - - - ), - }} + + ), + }} + values={{ + database: 'haveibeenpwned.com', + }} + /> + } /> - -
-
+
-
-
-
+ + -
-
- -
- - {t('auth.invitation.youreInvited')} - - - , - }} - /> - -
+ + + ), + }} + /> +
) diff --git a/web/app/pages/Dashboard/AddProject.tsx b/web/app/pages/Dashboard/AddProject.tsx index 3ec36424f..cc0012c4f 100644 --- a/web/app/pages/Dashboard/AddProject.tsx +++ b/web/app/pages/Dashboard/AddProject.tsx @@ -21,7 +21,7 @@ export const AddProject = ({ type='button' onClick={onClick} className={cx( - 'group cursor-pointer border-2 border-dashed border-gray-300 hover:border-gray-400', + 'group cursor-pointer border-2 border-dashed border-gray-300 hover:border-gray-400 dark:border-slate-700 dark:hover:border-slate-600', viewMode === 'list' ? 'flex h-[72px] items-center justify-center rounded-lg' : cx( @@ -35,11 +35,11 @@ export const AddProject = ({
- + {t('dashboard.newProject')}
diff --git a/web/app/routes/signup.invitation.$id.tsx b/web/app/routes/signup_.invitation.$id.tsx similarity index 85% rename from web/app/routes/signup.invitation.$id.tsx rename to web/app/routes/signup_.invitation.$id.tsx index f46eac496..8022534c9 100644 --- a/web/app/routes/signup.invitation.$id.tsx +++ b/web/app/routes/signup_.invitation.$id.tsx @@ -1,7 +1,9 @@ +import { useTranslation } from 'react-i18next' import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs, + MetaFunction, } from 'react-router' import { redirect, data } from 'react-router' @@ -10,9 +12,24 @@ import { getInvitationDetails, registerViaInvitation, } from '~/api/api.server' +import { getOgImageUrl } from '~/lib/constants' import InvitationSignup from '~/pages/Auth/Signup/InvitationSignup' +import { getDescription, getPreviewImage, getTitle } from '~/utils/seo' import { createHeadersWithCookies } from '~/utils/session.server' +export const meta: MetaFunction = () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { t } = useTranslation('common') + + return [ + ...getTitle(t('titles.signup')), + ...getDescription(t('description.signup')), + ...getPreviewImage( + getOgImageUrl(t('titles.signup'), t('description.signup')), + ), + ] +} + export const headers: HeadersFunction = ({ parentHeaders }) => { parentHeaders.set('X-Frame-Options', 'DENY') return parentHeaders diff --git a/web/app/ui/Input.tsx b/web/app/ui/Input.tsx index ba8022c1f..eb7b57318 100644 --- a/web/app/ui/Input.tsx +++ b/web/app/ui/Input.tsx @@ -16,6 +16,7 @@ interface InputProps { className?: string error?: string | null | boolean disabled?: boolean + readOnly?: boolean hintPosition?: 'top' | 'bottom' classes?: { input?: string @@ -31,6 +32,7 @@ const Input = ({ className, error, disabled, + readOnly, classes, hintPosition = 'bottom', ...rest @@ -49,13 +51,14 @@ const Input = ({ { 'text-red-900 placeholder-red-300 ring-red-600': isError, 'ring-gray-300 dark:ring-slate-700/80': !isError, - 'cursor-text bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-gray-400': - disabled, + 'cursor-text bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-gray-200': + disabled || readOnly, 'pr-10': isPassword, }, classes?.input, )} disabled={disabled} + readOnly={readOnly} invalid={isError} {...restWithoutType} type={isPassword && showPassword ? 'text' : type} diff --git a/web/app/ui/Text.tsx b/web/app/ui/Text.tsx index d16dc0707..fd0965644 100644 --- a/web/app/ui/Text.tsx +++ b/web/app/ui/Text.tsx @@ -70,7 +70,7 @@ const weightClasses: Record = { const colourClasses: Record = { primary: 'text-gray-900 dark:text-gray-50', secondary: 'text-gray-700 dark:text-gray-200', - muted: 'text-gray-600 dark:text-slate-400', + muted: 'text-gray-600 dark:text-slate-300', success: 'text-green-600 dark:text-green-400', warning: 'text-yellow-600 dark:text-yellow-500', error: 'text-red-600 dark:text-red-400', diff --git a/web/public/locales/en.json b/web/public/locales/en.json index dd9fe3a05..f180a341e 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -731,8 +731,6 @@ "invitedToJoin": "You've been invited to join {{targetName}}", "invitedByAs": "{{inviterEmail}} invited you as {{role}} to this {{type}}. Create your account to get started.", "createAndJoin": "Create account & join {{type}}", - "youreInvited": "You're invited", - "sidebarDesc": "Create your free account to access the analytics for {{targetName}}. No credit card required.", "project": "project", "organisation": "organisation" }, @@ -1781,7 +1779,7 @@ "pxCharsError": "Project name cannot be longer than {{amount}} characters.", "oxCharsError": "A list of allowed origins has to be smaller than {{amount}} symbols.", "noNameError": "Please enter a project name.", - "subscriptionRequired": "You need an active subscription to create personal projects. Please start a free trial or subscribe to a plan.", + "subscriptionRequired": "You need an active subscription to create personal projects. Please start a free trial or subscribe to a plan in your account settings.", "create": "Create a new project", "settings": "Settings of", "name": "Project name", From 606f22199e8eb6d38d2ab69d21675192a0491598 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 9 Mar 2026 23:04:21 +0000 Subject: [PATCH 3/9] Translations --- web/public/locales/de.json | 9 +++++++++ web/public/locales/fr.json | 9 +++++++++ web/public/locales/pl.json | 9 +++++++++ web/public/locales/uk.json | 9 +++++++++ 4 files changed, 36 insertions(+) diff --git a/web/public/locales/de.json b/web/public/locales/de.json index 2f1f9e3e6..861cf0b26 100644 --- a/web/public/locales/de.json +++ b/web/public/locales/de.json @@ -726,6 +726,14 @@ "intuitiveDesc": "Einfach zu bedienen, du musst kein Data Scientist sein." } }, + "invitation": { + "invalidLink": "Dieser Einladungslink ist ungültig oder abgelaufen.", + "invitedToJoin": "Sie wurden eingeladen, {{targetName}} beizutreten", + "invitedByAs": "{{inviterEmail}} hat Sie als {{role}} zu diesem/dieser {{type}} eingeladen. Erstellen Sie Ihr Konto, um loszulegen.", + "createAndJoin": "Konto erstellen & {{type}} beitreten", + "project": "Projekt", + "organisation": "Organisation" + }, "verification": { "success": "Deine E-Mail wurde erfolgreich verifiziert!", "continueToOnboarding": "Weiter zum Onboarding" @@ -1771,6 +1779,7 @@ "pxCharsError": "Der Projektname darf nicht länger als {{amount}} Zeichen sein.", "oxCharsError": "Die Liste der Allowed-Origins darf nicht kürzer als {{amount}} Zeichen sein.", "noNameError": "Bitte geben Sie einen Projektnamen ein.", + "subscriptionRequired": "Sie benötigen ein aktives Abonnement, um persönliche Projekte zu erstellen. Bitte starten Sie eine kostenlose Testversion oder abonnieren Sie einen Plan in Ihren Kontoeinstellungen.", "create": "Neues Projekt erstellen", "settings": "Einstellungen von", "name": "Projektname", diff --git a/web/public/locales/fr.json b/web/public/locales/fr.json index c6b7d4a88..8c5563463 100644 --- a/web/public/locales/fr.json +++ b/web/public/locales/fr.json @@ -726,6 +726,14 @@ "intuitiveDesc": "Facile à utiliser, pas besoin d’être data scientist." } }, + "invitation": { + "invalidLink": "Ce lien d'invitation est invalide ou a expiré.", + "invitedToJoin": "Vous avez été invité à rejoindre {{targetName}}", + "invitedByAs": "{{inviterEmail}} vous a invité en tant que {{role}} dans ce/cette {{type}}. Créez votre compte pour commencer.", + "createAndJoin": "Créer un compte et rejoindre {{type}}", + "project": "projet", + "organisation": "organisation" + }, "verification": { "success": "Votre e-mail a été vérifié avec succès !", "continueToOnboarding": "Continuer vers l’onboarding" @@ -1771,6 +1779,7 @@ "pxCharsError": "Le nom du projet ne peut pas dépasser {{amount}} caractères.", "oxCharsError": "La liste des origines autorisées doit être inférieure à {{amount}} symboles.", "noNameError": "Veuillez entrer un nom de projet.", + "subscriptionRequired": "Vous avez besoin d'un abonnement actif pour créer des projets personnels. Veuillez commencer un essai gratuit ou souscrire à un forfait dans les paramètres de votre compte.", "create": "Créer un nouveau projet", "settings": "Paramètres de", "name": "Nom du projet", diff --git a/web/public/locales/pl.json b/web/public/locales/pl.json index 17af512ef..7d02b64c6 100644 --- a/web/public/locales/pl.json +++ b/web/public/locales/pl.json @@ -726,6 +726,14 @@ "intuitiveDesc": "Łatwe w użyciu — nie musisz być data scientist." } }, + "invitation": { + "invalidLink": "Ten link z zaproszeniem jest nieprawidłowy lub wygasł.", + "invitedToJoin": "Zostałeś(-aś) zaproszony(-a) do dołączenia do {{targetName}}", + "invitedByAs": "{{inviterEmail}} zaprosił(-a) Cię jako {{role}} do tego/tej {{type}}. Utwórz konto, aby rozpocząć.", + "createAndJoin": "Utwórz konto i dołącz do {{type}}", + "project": "projekt", + "organisation": "organizacja" + }, "verification": { "success": "Twój adres e-mail został pomyślnie zweryfikowany!", "continueToOnboarding": "Przejdź do onboardingu" @@ -1771,6 +1779,7 @@ "pxCharsError": "Nazwa projektu nie może być dłuższa niż {{amount}} znaków.", "oxCharsError": "Lista dozwolonych źródeł musi być mniejsza niż {{amount}} znaków.", "noNameError": "Wprowadź nazwę projektu.", + "subscriptionRequired": "Potrzebujesz aktywnej subskrypcji, aby tworzyć projekty osobiste. Rozpocznij bezpłatny okres próbny lub wykup subskrypcję w ustawieniach konta.", "create": "Utwórz nowy projekt", "settings": "Ustawienia", "name": "Nazwa projektu", diff --git a/web/public/locales/uk.json b/web/public/locales/uk.json index 486e0dc96..0dd5cf261 100644 --- a/web/public/locales/uk.json +++ b/web/public/locales/uk.json @@ -726,6 +726,14 @@ "intuitiveDesc": "Просте у використанні — не потрібно бути дата-сайєнтистом." } }, + "invitation": { + "invalidLink": "Це посилання на запрошення недійсне або термін його дії минув.", + "invitedToJoin": "Вас запросили приєднатися до {{targetName}}", + "invitedByAs": "{{inviterEmail}} запросив(ла) вас як {{role}} до цього/цієї {{type}}. Створіть обліковий запис, щоб розпочати.", + "createAndJoin": "Створити обліковий запис та приєднатися до {{type}}", + "project": "проєкт", + "organisation": "організацію" + }, "verification": { "success": "Ваша електронна адреса була підтверджена!", "continueToOnboarding": "Продовжити ознайомлення з додатком" @@ -1771,6 +1779,7 @@ "pxCharsError": "Назва проєкту не можу бути довшою за {{amount}} символів.", "oxCharsError": "Список дозволених джерел повинен бути коротше за {{amount}} символів.", "noNameError": "Введіть назву проєкту.", + "subscriptionRequired": "Вам потрібна активна підписка для створення особистих проєктів. Будь ласка, почніть безкоштовний пробний період або підпишіться на тарифний план у налаштуваннях вашого облікового запису.", "create": "Створити новий проєкт", "settings": "Налаштування", "name": "Назва проєкту", From 562a380a9fca471cb8ff2e393da3897239867bc8 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 9 Mar 2026 23:09:14 +0000 Subject: [PATCH 4/9] fix: Claim pending invitation if user sign ups through a regular flow --- .../apps/cloud/src/auth/auth.controller.ts | 112 ++++++++++-------- backend/apps/cloud/src/auth/auth.service.ts | 107 ++++++++++++++++- web/app/api/api.server.ts | 20 ++++ web/app/routes/signup_.invitation.$id.tsx | 20 +++- 4 files changed, 206 insertions(+), 53 deletions(-) diff --git a/backend/apps/cloud/src/auth/auth.controller.ts b/backend/apps/cloud/src/auth/auth.controller.ts index d0ac49762..982953cd8 100644 --- a/backend/apps/cloud/src/auth/auth.controller.ts +++ b/backend/apps/cloud/src/auth/auth.controller.ts @@ -60,11 +60,10 @@ import { JwtRefreshTokenGuard, AuthenticationGuard, } from './guards' -import { ProjectService, deleteProjectRedis } from '../project/project.service' +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 { ProjectShare } from '../project/entity/project-share.entity' import { OrganisationService } from '../organisation/organisation.service' const OAUTH_RATE_LIMIT = 15 @@ -133,6 +132,16 @@ export class AuthController { body.password, ) + const hadPendingInvitations = + await this.authService.redeemPendingInvitations(newUser) + + if (hadPendingInvitations) { + await this.userService.updateUser(newUser.id, { + registeredViaInvitation: true, + hasCompletedOnboarding: true, + }) + } + await trackCustom(ip, headers['user-agent'], { ev: 'SIGNUP', meta: { @@ -142,9 +151,23 @@ export class AuthController { const jwtTokens = await this.authService.generateJwtTokens(newUser.id, true) + const updatedUser = hadPendingInvitations + ? await this.userService.findUserById(newUser.id) + : newUser + + if (hadPendingInvitations) { + 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, } } @@ -488,6 +511,42 @@ export class AuthController { } } + @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', @@ -547,52 +606,7 @@ export class AuthController { registeredViaInvitation: true, }) - const allPendingInvitations = - await this.pendingInvitationService.findByEmail(body.email) - - for (const pending of allPendingInvitations) { - if ( - pending.type === PendingInvitationType.PROJECT_SHARE && - pending.projectId - ) { - const project = await this.projectService.findOne({ - where: { id: pending.projectId }, - relations: ['share'], - }) - - if (project) { - const share = new ProjectShare() - share.role = pending.role as any - share.user = newUser - share.project = project - share.confirmed = true - - await this.projectService.createShare(share) - await deleteProjectRedis(project.id) - } - } else if ( - pending.type === PendingInvitationType.ORGANISATION_MEMBER && - pending.organisationId - ) { - const organisation = await this.organisationService.findOne({ - where: { id: pending.organisationId }, - }) - - if (organisation) { - await this.organisationService.createMembership({ - role: pending.role as any, - user: newUser, - organisation, - confirmed: true, - }) - await this.organisationService.deleteOrganisationProjectsFromRedis( - organisation.id, - ) - } - } - } - - await this.pendingInvitationService.deleteAllByEmail(body.email) + await this.authService.redeemPendingInvitations(newUser) await trackCustom(ip, headers['user-agent'], { ev: 'SIGNUP', diff --git a/backend/apps/cloud/src/auth/auth.service.ts b/backend/apps/cloud/src/auth/auth.service.ts index 53666aa3b..043b2af3f 100644 --- a/backend/apps/cloud/src/auth/auth.service.ts +++ b/backend/apps/cloud/src/auth/auth.service.ts @@ -47,6 +47,11 @@ import { SSOProviders } from './dtos' import { UserGoogleDTO } from '../user/dto/user-google.dto' import { UserGithubDTO } from '../user/dto/user-github.dto' import { trackCustom } from '../common/analytics' +import { PendingInvitationService } from '../pending-invitation/pending-invitation.service' +import { PendingInvitationType } from '../pending-invitation/pending-invitation.entity' +import { ProjectShare } from '../project/entity/project-share.entity' +import { deleteProjectRedis } from '../project/project.service' +import { OrganisationService } from '../organisation/organisation.service' const REDIS_SSO_SESSION_TIMEOUT = 60 * 5 // 5 minutes const getSSORedisKey = (uuid: string) => `${REDIS_SSO_UUID}:${uuid}` @@ -85,6 +90,9 @@ export class AuthService { private readonly telegramService: TelegramService, @Inject(forwardRef(() => TwoFactorAuthService)) private readonly twoFactorAuthService: TwoFactorAuthService, + private readonly pendingInvitationService: PendingInvitationService, + @Inject(forwardRef(() => OrganisationService)) + private readonly organisationService: OrganisationService, ) { this.oauth2Client = new OAuth2Client( this.configService.get('GOOGLE_OAUTH2_CLIENT_ID'), @@ -181,6 +189,75 @@ export class AuthService { return user } + public async redeemPendingInvitations(user: User): Promise { + const pendingInvitations = await this.pendingInvitationService.findByEmail( + user.email, + ) + + if (!pendingInvitations.length) { + return false + } + + for (const pending of pendingInvitations) { + if ( + pending.type === PendingInvitationType.PROJECT_SHARE && + pending.projectId + ) { + const project = await this.projectService.findOne({ + where: { id: pending.projectId }, + relations: ['share'], + }) + + if (project) { + const alreadyShared = project.share?.some( + (s) => s.user?.id === user.id, + ) + + if (!alreadyShared) { + const share = new ProjectShare() + share.role = pending.role as any + share.user = user + share.project = project + share.confirmed = true + + await this.projectService.createShare(share) + await deleteProjectRedis(project.id) + } + } + } else if ( + pending.type === PendingInvitationType.ORGANISATION_MEMBER && + pending.organisationId + ) { + const organisation = await this.organisationService.findOne({ + where: { id: pending.organisationId }, + relations: ['members', 'members.user'], + }) + + if (organisation) { + const alreadyMember = organisation.members?.some( + (m) => m.user?.id === user.id, + ) + + if (!alreadyMember) { + await this.organisationService.createMembership({ + role: pending.role as any, + user, + organisation, + confirmed: true, + }) + await this.organisationService.deleteOrganisationProjectsFromRedis( + organisation.id, + ) + } + } + } + } + + await this.pendingInvitationService.deleteAllByEmail(user.email) + + return true + } + public async generateJwtAccessToken( userId: string, isSecondFactorAuthenticated = false, @@ -590,11 +667,24 @@ export class AuthService { const user = await this.userService.create(query) + const hadPendingInvitations = await this.redeemPendingInvitations(user) + + if (hadPendingInvitations) { + await this.userService.updateUser(user.id, { + registeredViaInvitation: true, + hasCompletedOnboarding: true, + }) + } + const jwtTokens = await this.generateJwtTokens(user.id, true) + const finalUser = hadPendingInvitations + ? await this.userService.findUserById(user.id) + : user + return { ...jwtTokens, - user: this.userService.omitSensitiveData(user), + user: this.userService.omitSensitiveData(finalUser), totalMonthlyEvents: 0, } } @@ -990,11 +1080,24 @@ export class AuthService { const user = await this.userService.create(query) + const hadPendingInvitations = await this.redeemPendingInvitations(user) + + if (hadPendingInvitations) { + await this.userService.updateUser(user.id, { + registeredViaInvitation: true, + hasCompletedOnboarding: true, + }) + } + const jwtTokens = await this.generateJwtTokens(user.id, true) + const finalUser = hadPendingInvitations + ? await this.userService.findUserById(user.id) + : user + return { ...jwtTokens, - user: this.userService.omitSensitiveData(user), + user: this.userService.omitSensitiveData(finalUser), totalMonthlyEvents: 0, } } diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 69a269511..5af9073b8 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -483,6 +483,26 @@ export async function getInvitationDetails( } } +export async function claimInvitation( + request: Request, + invitationId: string, +): Promise<{ success: boolean; error?: string }> { + const result = await serverFetch<{ success: boolean }>( + request, + `v1/auth/invitation/${invitationId}/claim`, + { method: 'POST' }, + ) + + if (result.error) { + return { + success: false, + error: Array.isArray(result.error) ? result.error[0] : result.error, + } + } + + return { success: true } +} + export async function registerViaInvitation( request: Request, data: { diff --git a/web/app/routes/signup_.invitation.$id.tsx b/web/app/routes/signup_.invitation.$id.tsx index 8022534c9..b5e00cc71 100644 --- a/web/app/routes/signup_.invitation.$id.tsx +++ b/web/app/routes/signup_.invitation.$id.tsx @@ -11,6 +11,7 @@ import { getAuthenticatedUser, getInvitationDetails, registerViaInvitation, + claimInvitation, } from '~/api/api.server' import { getOgImageUrl } from '~/lib/constants' import InvitationSignup from '~/pages/Auth/Signup/InvitationSignup' @@ -66,12 +67,27 @@ export async function loader({ const authResult = await getAuthenticatedUser(request) if (authResult) { + const { id: invId } = params + + if (invId) { + await claimInvitation(request, invId) + } + const user = authResult.user.user + const cookies = authResult.cookies || [] + const redirectHeaders = cookies.length + ? createHeadersWithCookies(cookies) + : undefined if (!user.hasCompletedOnboarding) { - throw redirect('/onboarding') + throw redirect('/onboarding', { + headers: redirectHeaders, + }) } - throw redirect('/dashboard') + + throw redirect('/dashboard', { + headers: redirectHeaders, + }) } const { id } = params From 417f45fbc3757fd7a9ff6d0214cae6bc8f2e9cd2 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 9 Mar 2026 23:17:00 +0000 Subject: [PATCH 5/9] fix: Redeem invitation logic --- .../apps/cloud/src/auth/auth.controller.ts | 59 ++++++++++++-- backend/apps/cloud/src/auth/auth.service.ts | 28 +++---- .../pages/Auth/Signup/InvitationSignup.tsx | 76 ++++++++++--------- web/app/routes/signup_.invitation.$id.tsx | 4 +- 4 files changed, 107 insertions(+), 60 deletions(-) diff --git a/backend/apps/cloud/src/auth/auth.controller.ts b/backend/apps/cloud/src/auth/auth.controller.ts index 982953cd8..15021e3c9 100644 --- a/backend/apps/cloud/src/auth/auth.controller.ts +++ b/backend/apps/cloud/src/auth/auth.controller.ts @@ -132,10 +132,10 @@ export class AuthController { body.password, ) - const hadPendingInvitations = + const redeemedCount = await this.authService.redeemPendingInvitations(newUser) - if (hadPendingInvitations) { + if (redeemedCount > 0) { await this.userService.updateUser(newUser.id, { registeredViaInvitation: true, hasCompletedOnboarding: true, @@ -151,11 +151,12 @@ export class AuthController { const jwtTokens = await this.authService.generateJwtTokens(newUser.id, true) - const updatedUser = hadPendingInvitations - ? await this.userService.findUserById(newUser.id) - : newUser + const updatedUser = + redeemedCount > 0 + ? await this.userService.findUserById(newUser.id) + : newUser - if (hadPendingInvitations) { + if (redeemedCount > 0) { const [sharedProjects, organisationMemberships] = await Promise.all([ this.authService.getSharedProjectsForUser(newUser.id), this.userService.getOrganisationsForUser(newUser.id), @@ -490,7 +491,13 @@ export class AuthController { const project = await this.projectService.findOne({ where: { id: invitation.projectId }, }) - targetName = project?.name || '' + + 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 @@ -498,7 +505,13 @@ export class AuthController { const organisation = await this.organisationService.findOne({ where: { id: invitation.organisationId }, }) - targetName = organisation?.name || '' + + if (!organisation) { + await this.pendingInvitationService.delete(invitation.id) + throw new NotFoundException('Invitation not found or has expired') + } + + targetName = organisation.name } return { @@ -576,6 +589,36 @@ export class AuthController { 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) { diff --git a/backend/apps/cloud/src/auth/auth.service.ts b/backend/apps/cloud/src/auth/auth.service.ts index 043b2af3f..3307125e6 100644 --- a/backend/apps/cloud/src/auth/auth.service.ts +++ b/backend/apps/cloud/src/auth/auth.service.ts @@ -189,15 +189,17 @@ export class AuthService { return user } - public async redeemPendingInvitations(user: User): Promise { + public async redeemPendingInvitations(user: User): Promise { const pendingInvitations = await this.pendingInvitationService.findByEmail( user.email, ) if (!pendingInvitations.length) { - return false + return 0 } + let redeemed = 0 + for (const pending of pendingInvitations) { if ( pending.type === PendingInvitationType.PROJECT_SHARE && @@ -222,6 +224,7 @@ export class AuthService { await this.projectService.createShare(share) await deleteProjectRedis(project.id) + redeemed++ } } } else if ( @@ -248,6 +251,7 @@ export class AuthService { await this.organisationService.deleteOrganisationProjectsFromRedis( organisation.id, ) + redeemed++ } } } @@ -255,7 +259,7 @@ export class AuthService { await this.pendingInvitationService.deleteAllByEmail(user.email) - return true + return redeemed } public async generateJwtAccessToken( @@ -667,9 +671,9 @@ export class AuthService { const user = await this.userService.create(query) - const hadPendingInvitations = await this.redeemPendingInvitations(user) + const redeemedCount = await this.redeemPendingInvitations(user) - if (hadPendingInvitations) { + if (redeemedCount > 0) { await this.userService.updateUser(user.id, { registeredViaInvitation: true, hasCompletedOnboarding: true, @@ -678,9 +682,8 @@ export class AuthService { const jwtTokens = await this.generateJwtTokens(user.id, true) - const finalUser = hadPendingInvitations - ? await this.userService.findUserById(user.id) - : user + const finalUser = + redeemedCount > 0 ? await this.userService.findUserById(user.id) : user return { ...jwtTokens, @@ -1080,9 +1083,9 @@ export class AuthService { const user = await this.userService.create(query) - const hadPendingInvitations = await this.redeemPendingInvitations(user) + const redeemedCount = await this.redeemPendingInvitations(user) - if (hadPendingInvitations) { + if (redeemedCount > 0) { await this.userService.updateUser(user.id, { registeredViaInvitation: true, hasCompletedOnboarding: true, @@ -1091,9 +1094,8 @@ export class AuthService { const jwtTokens = await this.generateJwtTokens(user.id, true) - const finalUser = hadPendingInvitations - ? await this.userService.findUserById(user.id) - : user + const finalUser = + redeemedCount > 0 ? await this.userService.findUserById(user.id) : user return { ...jwtTokens, diff --git a/web/app/pages/Auth/Signup/InvitationSignup.tsx b/web/app/pages/Auth/Signup/InvitationSignup.tsx index fa63b03d7..6222f3e9b 100644 --- a/web/app/pages/Auth/Signup/InvitationSignup.tsx +++ b/web/app/pages/Auth/Signup/InvitationSignup.tsx @@ -10,7 +10,7 @@ import { } from 'react-router' import { toast } from 'sonner' -import { HAVE_I_BEEN_PWNED_URL } from '~/lib/constants' +import { HAVE_I_BEEN_PWNED_URL, isSelfhosted } from '~/lib/constants' import type { InvitationSignupLoaderData, InvitationSignupActionData, @@ -155,42 +155,44 @@ const InvitationSignup = () => { value={checkIfLeaked ? 'true' : 'false'} /> - { - setTos(checked) - clearFieldError('tos') - }} - disabled={isFormSubmitting} - label={ - - - ), - pp: ( - - ), - }} - /> - - } - classes={{ - hint: '!text-red-600 dark:!text-red-500', - }} - hint={getFieldError('tos')} - /> + {isSelfhosted ? null : ( + { + setTos(checked) + clearFieldError('tos') + }} + disabled={isFormSubmitting} + label={ + + + ), + pp: ( + + ), + }} + /> + + } + classes={{ + hint: '!text-red-600 dark:!text-red-500', + }} + hint={getFieldError('tos')} + /> + )}
Date: Mon, 9 Mar 2026 23:19:57 +0000 Subject: [PATCH 6/9] fix: project members are not deletable --- web/app/routes/projects.settings.$id.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 1079b9b61..3e8af894c 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -492,16 +492,16 @@ export async function action({ request, params }: ActionFunctionArgs) { } case 'delete-share-user': { - const userId = formData.get('userId')?.toString() + const shareId = formData.get('shareId')?.toString() - if (!userId) { + if (!shareId) { return data( - { intent, error: 'User ID is required' }, + { intent, error: 'Share ID is required' }, { status: 400 }, ) } - const result = await serverFetch(request, `project/${id}/${userId}`, { + const result = await serverFetch(request, `project/${id}/${shareId}`, { method: 'DELETE', }) From 68d6568c3f66735734809e82eee37ef3b20d25dc Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 9 Mar 2026 23:21:33 +0000 Subject: [PATCH 7/9] max password - 72 chars --- web/app/utils/validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/utils/validator.ts b/web/app/utils/validator.ts index fe59e4ed2..01ba6fce4 100644 --- a/web/app/utils/validator.ts +++ b/web/app/utils/validator.ts @@ -1,5 +1,5 @@ export const MIN_PASSWORD_CHARS = 8 -export const MAX_PASSWORD_CHARS = 50 +export const MAX_PASSWORD_CHARS = 72 export const isValidEmail = (text: string) => text.match(/^\S+@\S+\.\S+$/) From a296f11f1283a4437144d38adfadfba23fdad1da Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 9 Mar 2026 23:22:43 +0000 Subject: [PATCH 8/9] fix: max password length is hardcoded --- web/app/routes/signup.tsx | 5 +++-- web/app/routes/signup_.invitation.$id.tsx | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/app/routes/signup.tsx b/web/app/routes/signup.tsx index ee4ed43a5..571f365bc 100644 --- a/web/app/routes/signup.tsx +++ b/web/app/routes/signup.tsx @@ -13,6 +13,7 @@ import { getOgImageUrl, isSelfhosted } from '~/lib/constants' import Signup from '~/pages/Auth/Signup' import { getDescription, getPreviewImage, getTitle } from '~/utils/seo' import { createHeadersWithCookies } from '~/utils/session.server' +import { MAX_PASSWORD_CHARS } from '~/utils/validator' export const meta: MetaFunction = () => { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -80,8 +81,8 @@ export async function action({ request }: ActionFunctionArgs) { fieldErrors.password = 'Password must be at least 8 characters' } - if (password.length > 50) { - fieldErrors.password = 'Password must be at most 50 characters' + if (password.length > MAX_PASSWORD_CHARS) { + fieldErrors.password = `Password must be at most ${MAX_PASSWORD_CHARS} characters` } if (!tos && !isSelfhosted) { diff --git a/web/app/routes/signup_.invitation.$id.tsx b/web/app/routes/signup_.invitation.$id.tsx index 94ed141d5..a60baca96 100644 --- a/web/app/routes/signup_.invitation.$id.tsx +++ b/web/app/routes/signup_.invitation.$id.tsx @@ -17,6 +17,7 @@ import { getOgImageUrl, isSelfhosted } from '~/lib/constants' import InvitationSignup from '~/pages/Auth/Signup/InvitationSignup' import { getDescription, getPreviewImage, getTitle } from '~/utils/seo' import { createHeadersWithCookies } from '~/utils/session.server' +import { MAX_PASSWORD_CHARS } from '~/utils/validator' export const meta: MetaFunction = () => { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -124,8 +125,8 @@ export async function action({ request, params }: ActionFunctionArgs) { fieldErrors.password = 'Password must be at least 8 characters' } - if (password.length > 50) { - fieldErrors.password = 'Password must be at most 50 characters' + if (password.length > MAX_PASSWORD_CHARS) { + fieldErrors.password = `Password must be at most ${MAX_PASSWORD_CHARS} characters` } if (!tos && !isSelfhosted) { From 0ed2da5f2b37ae0f31bf74cd8ff277a4a1423f1e Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 9 Mar 2026 23:24:08 +0000 Subject: [PATCH 9/9] fix: max project password length mismatch --- backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts | 2 +- backend/apps/cloud/src/auth/dtos/register.dto.ts | 2 +- backend/apps/cloud/src/auth/dtos/request-change-email.dto.ts | 2 +- backend/apps/cloud/src/auth/dtos/reset-password.dto.ts | 2 +- backend/apps/community/src/auth/dtos/register.dto.ts | 2 +- .../apps/community/src/auth/dtos/request-change-email.dto.ts | 2 +- backend/apps/community/src/auth/dtos/reset-password.dto.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts b/backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts index fffaf03f2..9f244deee 100644 --- a/backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts +++ b/backend/apps/cloud/src/auth/dtos/register-invitation.dto.ts @@ -31,7 +31,7 @@ export class RegisterInvitationRequestDto { maxLength: 72, minLength: 8, }) - @MaxLength(50, { message: 'Max length is $constraint1 characters' }) + @MaxLength(72, { message: 'Max length is $constraint1 characters' }) @MinLength(8, { message: 'Min length is $constraint1 characters' }) public readonly password: string diff --git a/backend/apps/cloud/src/auth/dtos/register.dto.ts b/backend/apps/cloud/src/auth/dtos/register.dto.ts index 4684b580b..3b1ecd7b2 100644 --- a/backend/apps/cloud/src/auth/dtos/register.dto.ts +++ b/backend/apps/cloud/src/auth/dtos/register.dto.ts @@ -23,7 +23,7 @@ export class RegisterRequestDto { maxLength: 72, minLength: 8, }) - @MaxLength(50, { message: 'Max length is $constraint1 characters' }) + @MaxLength(72, { message: 'Max length is $constraint1 characters' }) @MinLength(8, { message: 'Min length is $constraint1 characters' }) public readonly password: string diff --git a/backend/apps/cloud/src/auth/dtos/request-change-email.dto.ts b/backend/apps/cloud/src/auth/dtos/request-change-email.dto.ts index 6c1bde5db..b280e38a7 100644 --- a/backend/apps/cloud/src/auth/dtos/request-change-email.dto.ts +++ b/backend/apps/cloud/src/auth/dtos/request-change-email.dto.ts @@ -17,7 +17,7 @@ export class RequestChangeEmailDto { maxLength: 72, minLength: 8, }) - @MaxLength(50, { message: 'Max length is $constraint1 characters' }) + @MaxLength(72, { message: 'Max length is $constraint1 characters' }) @MinLength(8, { message: 'Min length is $constraint1 characters' }) public readonly password: string } diff --git a/backend/apps/cloud/src/auth/dtos/reset-password.dto.ts b/backend/apps/cloud/src/auth/dtos/reset-password.dto.ts index 14df63e68..0e7746bb4 100644 --- a/backend/apps/cloud/src/auth/dtos/reset-password.dto.ts +++ b/backend/apps/cloud/src/auth/dtos/reset-password.dto.ts @@ -8,7 +8,7 @@ export class ResetPasswordDto { maxLength: 72, minLength: 8, }) - @MaxLength(50, { message: 'Max length is $constraint1 characters' }) + @MaxLength(72, { message: 'Max length is $constraint1 characters' }) @MinLength(8, { message: 'Min length is $constraint1 characters' }) public readonly newPassword: string } diff --git a/backend/apps/community/src/auth/dtos/register.dto.ts b/backend/apps/community/src/auth/dtos/register.dto.ts index 05c4d0e39..96c1d5b4f 100644 --- a/backend/apps/community/src/auth/dtos/register.dto.ts +++ b/backend/apps/community/src/auth/dtos/register.dto.ts @@ -23,7 +23,7 @@ export class RegisterRequestDto { maxLength: 72, minLength: 8, }) - @MaxLength(50, { message: 'Max length is $constraint1 characters' }) + @MaxLength(72, { message: 'Max length is $constraint1 characters' }) @MinLength(8, { message: 'Min length is $constraint1 characters' }) public readonly password: string diff --git a/backend/apps/community/src/auth/dtos/request-change-email.dto.ts b/backend/apps/community/src/auth/dtos/request-change-email.dto.ts index dc7e3ebd7..dd3e3df60 100644 --- a/backend/apps/community/src/auth/dtos/request-change-email.dto.ts +++ b/backend/apps/community/src/auth/dtos/request-change-email.dto.ts @@ -17,7 +17,7 @@ export class RequestChangeEmailDto { maxLength: 72, minLength: 8, }) - @MaxLength(50, { message: 'Max length is $constraint1 characters' }) + @MaxLength(72, { message: 'Max length is $constraint1 characters' }) @MinLength(8, { message: 'Min length is $constraint1 characters' }) public readonly password: string } diff --git a/backend/apps/community/src/auth/dtos/reset-password.dto.ts b/backend/apps/community/src/auth/dtos/reset-password.dto.ts index 14df63e68..0e7746bb4 100644 --- a/backend/apps/community/src/auth/dtos/reset-password.dto.ts +++ b/backend/apps/community/src/auth/dtos/reset-password.dto.ts @@ -8,7 +8,7 @@ export class ResetPasswordDto { maxLength: 72, minLength: 8, }) - @MaxLength(50, { message: 'Max length is $constraint1 characters' }) + @MaxLength(72, { message: 'Max length is $constraint1 characters' }) @MinLength(8, { message: 'Min length is $constraint1 characters' }) public readonly newPassword: string }