Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/backend/src/api/routes/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export class AuthController {
getOrgFromCookie
);

this._authService.consumeOrgInviteCookie(
req?.cookies?.org,
getOrgFromCookie,
response
);

const activationRequired =
body.provider === 'LOCAL' && this._emailService.hasProvider();

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/api/routes/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 45 additions & 0 deletions apps/backend/src/services/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<AuthService['getOrgFromCookie']>,
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,
Expand Down
53 changes: 53 additions & 0 deletions apps/frontend/src/components/layout/invitation.message.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 2 additions & 0 deletions apps/frontend/src/components/new-layout/layout.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,6 +81,7 @@ export const LayoutComponent = ({ children }: { children: ReactNode }) => {
<ToolTip />
<Toaster />
<TrialTracker />
<InvitationMessage />
<CheckPayment check={searchParams.get('check') || ''} mutate={mutate}>
<ShowMediaBoxModal />
<ShowLinkedinCompany />
Expand Down
15 changes: 8 additions & 7 deletions apps/frontend/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,21 @@ 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,
}),
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)
);
Expand All @@ -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;
Expand Down
Loading