From c7cba288a94475a161f6074fd772bb26ff9a2891 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Fri, 26 Jun 2026 17:09:38 +0700 Subject: [PATCH] feat: show "invitation expired" message for late team-invite acceptances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user accepted a team invitation after the 1-hour link expiry, they were silently not added to the inviting org and dropped on the pricing page with no explanation. Surface an "invitation expired" toast across every acceptance path. - proxy: already-logged-in invitees redirect to the landing page with ?invitation=expired instead of the (query-stripping) "/" hop - register/login/oauth: a single-use `org` invite cookie is handled by AuthService.consumeOrgInviteCookie, which flags expiry via a short-lived `invitation` cookie (so it survives the email-activation detour) and then always clears the `org` cookie so a consumed/expired token can't leak to the next user who signs in on the same browser - frontend: InvitationMessage shows the toast from the query param or the cookie, once, then clears both Tested locally (temporarily lowering the invite expiry to 1 minute): - register (brand-new account) — expired invite shows the toast - activation flow (LOCAL + email provider) — toast survives the register -> email -> activate round-trip and shows after landing - login (existing, logged-out invitee) — expired invite shows the toast - cookie reuse — after the first auth event the `org` cookie is cleared, so logging in as a different user afterwards (e.g. the inviting org owner) no longer shows the toast Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/src/api/routes/auth.controller.ts | 24 +++++++++ .../src/api/routes/users.controller.ts | 2 +- .../backend/src/services/auth/auth.service.ts | 45 ++++++++++++++++ .../components/layout/invitation.message.tsx | 53 +++++++++++++++++++ .../new-layout/layout.component.tsx | 2 + apps/frontend/src/proxy.ts | 15 +++--- 6 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 apps/frontend/src/components/layout/invitation.message.tsx diff --git a/apps/backend/src/api/routes/auth.controller.ts b/apps/backend/src/api/routes/auth.controller.ts index 14bc25f94d..1326e54a49 100644 --- a/apps/backend/src/api/routes/auth.controller.ts +++ b/apps/backend/src/api/routes/auth.controller.ts @@ -60,6 +60,12 @@ export class AuthController { getOrgFromCookie ); + this._authService.consumeOrgInviteCookie( + req?.cookies?.org, + getOrgFromCookie, + response + ); + const activationRequired = body.provider === 'LOCAL' && this._emailService.hasProvider(); @@ -134,6 +140,12 @@ export class AuthController { getOrgFromCookie ); + this._authService.consumeOrgInviteCookie( + req?.cookies?.org, + getOrgFromCookie, + response + ); + response.cookie('auth', jwt, { domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), ...(!process.env.NOT_SECURED @@ -269,6 +281,7 @@ export class AuthController { @Post('/oauth/:provider/exists') async oauthExists( + @Req() req: Request, @Body('code') code: string, @Body('redirect_uri') redirect_uri: string, @Param('provider') provider: string, @@ -284,6 +297,17 @@ export class AuthController { return response.json({ token }); } + // Existing-user OAuth login: this path doesn't run routeAuth, so handle the + // org invite cookie here too. See register/login for the same logic. + const getOrgFromCookie = this._authService.getOrgFromCookie( + req?.cookies?.org + ); + this._authService.consumeOrgInviteCookie( + req?.cookies?.org, + getOrgFromCookie, + response + ); + response.cookie('auth', jwt, { domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), ...(!process.env.NOT_SECURED diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 98e888d72c..dc4bc741d8 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -236,7 +236,7 @@ export class UsersController { const getOrgFromCookie = this._authService.getOrgFromCookie(org); if (!getOrgFromCookie) { - return response.status(200).json({ id: null }); + return response.status(200).json({ id: null, expired: true }); } const addedOrg = await this._orgService.addUserToOrg( diff --git a/apps/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index 187c08ed5a..cb15383150 100644 --- a/apps/backend/src/services/auth/auth.service.ts +++ b/apps/backend/src/services/auth/auth.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { Response } from 'express'; import { Provider, User } from '@prisma/client'; +import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto'; import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto'; import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; @@ -134,6 +136,49 @@ export class AuthService { } } + // Handles the `org` invite token cookie at an auth event (register/login/ + // oauth). The token is single-use: if it was expired/invalid, flag it so the + // frontend can show an "invitation expired" toast to the (late) invitee; then + // always clear the cookie so it can't leak to the next user who signs in on + // the same browser (e.g. the inviting org owner). + public consumeOrgInviteCookie( + orgCookie: string | undefined, + getOrgFromCookie: ReturnType, + response: Response + ) { + if (!orgCookie) { + return; + } + + if (!getOrgFromCookie) { + // Non-httpOnly on purpose: the frontend reads & clears it. A cookie is + // used (not a header/param) because the LOCAL signup flow detours through + // email activation before the invitee reaches the app. + response.cookie('invitation', 'expired', { + domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), + ...(!process.env.NOT_SECURED + ? { + secure: true, + sameSite: 'none', + } + : {}), + // Long enough to outlive the register -> email activation -> land + // round-trip; the frontend clears it as soon as the toast is shown. + expires: dayjs().add(1, 'day').toDate(), + }); + } + + // Mirror the path/domain the proxy set it with so the deletion matches. + response.clearCookie('org', { + ...(!process.env.NOT_SECURED + ? { + path: '/', + domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), + } + : {}), + }); + } + private async loginOrRegisterProvider( provider: Provider, body: CreateOrgUserDto, diff --git a/apps/frontend/src/components/layout/invitation.message.tsx b/apps/frontend/src/components/layout/invitation.message.tsx new file mode 100644 index 0000000000..5a34224dad --- /dev/null +++ b/apps/frontend/src/components/layout/invitation.message.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { FC, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { getCookie, setCookie } from 'react-use-cookie'; +import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; +import { useToaster } from '@gitroom/react/toaster/toaster'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useVariables } from '@gitroom/react/helpers/variable.context'; + +export const InvitationMessage: FC = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + const toaster = useToaster(); + const t = useT(); + const { frontEndUrl } = useVariables(); + const invitation = searchParams.get('invitation'); + + useEffect(() => { + // ?invitation=expired => logged-in / direct-link path (set by the proxy). + // invitation cookie => account-creation / login path (set by the backend), + // which survives the email-activation detour before the user lands in-app. + const expired = + invitation === 'expired' || getCookie('invitation') === 'expired'; + if (!expired) { + return; + } + + toaster.show( + t( + 'invitation_expired', + 'Your invitation link has expired or is no longer valid. Please ask the team to send you a new one.' + ), + 'warning' + ); + + if (invitation) { + const url = new URL(window.location.href); + url.searchParams.delete('invitation'); + router.replace(url.toString()); + } + + // Clear the cookie on the same domain the backend set it on, otherwise it + // would re-trigger the toast on every page load. + setCookie('invitation', '', { + days: -1, + path: '/', + domain: getCookieUrlFromDomain(frontEndUrl), + }); + }, [invitation]); + + return null; +}; diff --git a/apps/frontend/src/components/new-layout/layout.component.tsx b/apps/frontend/src/components/new-layout/layout.component.tsx index 71e66ed4f6..b28519ad75 100644 --- a/apps/frontend/src/components/new-layout/layout.component.tsx +++ b/apps/frontend/src/components/new-layout/layout.component.tsx @@ -26,6 +26,7 @@ import { ShowPostSelector } from '@gitroom/frontend/components/post-url-selector import { NewSubscription } from '@gitroom/frontend/components/layout/new.subscription'; import { Support } from '@gitroom/frontend/components/layout/support'; import { ContinueProvider } from '@gitroom/frontend/components/layout/continue.provider'; +import { InvitationMessage } from '@gitroom/frontend/components/layout/invitation.message'; import { ContextWrapper } from '@gitroom/frontend/components/layout/user.context'; import { CopilotKit } from '@copilotkit/react-core'; import { MantineWrapper } from '@gitroom/react/helpers/mantine.wrapper'; @@ -80,6 +81,7 @@ export const LayoutComponent = ({ children }: { children: ReactNode }) => { + diff --git a/apps/frontend/src/proxy.ts b/apps/frontend/src/proxy.ts index 360c937e34..79a333b64e 100644 --- a/apps/frontend/src/proxy.ts +++ b/apps/frontend/src/proxy.ts @@ -129,8 +129,9 @@ export async function proxy(request: NextRequest) { return topResponse; } try { + const landingPath = !!process.env.IS_GENERAL ? '/launches' : '/analytics'; if (org) { - const { id } = await ( + const { id, expired } = await ( await internalFetch('/user/join-org', { body: JSON.stringify({ org, @@ -138,6 +139,11 @@ export async function proxy(request: NextRequest) { method: 'POST', }) ).json(); + if (expired) { + return NextResponse.redirect( + new URL(`${landingPath}?invitation=expired`, nextUrl.href) + ); + } const redirect = NextResponse.redirect( new URL(`/?added=true`, nextUrl.href) ); @@ -158,12 +164,7 @@ export async function proxy(request: NextRequest) { return redirect; } if (nextUrl.pathname === '/') { - return NextResponse.redirect( - new URL( - !!process.env.IS_GENERAL ? '/launches' : `/analytics`, - nextUrl.href - ) - ); + return NextResponse.redirect(new URL(landingPath, nextUrl.href)); } return topResponse;