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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ NEXT_PUBLIC_POLOTNO=""
# NOT_SECURED=false
API_LIMIT=30 # The limit of the public API hour limit

# Sentry Error Tracking Settings (optional)
# NEXT_PUBLIC_SENTRY_DSN=""

# Payment settings
FEE_AMOUNT=0.05
STRIPE_PUBLISHABLE_KEY=""
Expand Down Expand Up @@ -117,4 +120,4 @@ POSTIZ_OAUTH_CLIENT_SECRET=""

# LINK_DRIP_API_KEY="" # Your LinkDrip API key
# LINK_DRIP_API_ENDPOINT="https://api.linkdrip.com/v1/" # Your self-hosted LinkDrip API endpoint
# LINK_DRIP_SHORT_LINK_DOMAIN="dripl.ink" # Your self-hosted LinkDrip domain
# LINK_DRIP_SHORT_LINK_DOMAIN="dripl.ink" # Your self-hosted LinkDrip domain
4 changes: 4 additions & 0 deletions apps/backend/src/api/routes/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum';
import { TrackService } from '@gitroom/nestjs-libraries/track/track.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/permissions/permission.exception.class';
import { clearSentryUserContext } from '@gitroom/nestjs-libraries/sentry/sentry.user.context';

@ApiTags('User')
@Controller('/user')
Expand Down Expand Up @@ -199,6 +200,9 @@ export class UsersController {

@Post('/logout')
logout(@Res({ passthrough: true }) response: Response) {
// Clear Sentry user context on logout
clearSentryUserContext();

response.header('logout', 'true');
response.cookie('auth', '', {
domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!),
Expand Down
12 changes: 12 additions & 0 deletions apps/backend/src/services/auth/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
import { setSentryUserContext } from '@gitroom/nestjs-libraries/sentry/sentry.user.context';
import { MastraService } from '@gitroom/nestjs-libraries/chat/mastra.service';

export const removeAuth = (res: Response) => {
Expand Down Expand Up @@ -33,6 +34,8 @@ export class AuthMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.auth || req.cookies.auth;
if (!auth) {
// Clear Sentry user context when no auth token is present
setSentryUserContext(null);
throw new HttpForbiddenException();
}
try {
Expand Down Expand Up @@ -70,6 +73,10 @@ export class AuthMiddleware implements NestMiddleware {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.org = loadImpersonate.organization;

// Set Sentry user context for impersonated user
setSentryUserContext(user);

next();
return;
}
Expand Down Expand Up @@ -97,7 +104,12 @@ export class AuthMiddleware implements NestMiddleware {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.org = setOrg;

// Set Sentry user context for this request
setSentryUserContext(user);
} catch (err) {
// Clear Sentry user context on authentication failure
setSentryUserContext(null);
throw new HttpForbiddenException();
}
next();
Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/components/layout/logout.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useVariables } from '@gitroom/react/helpers/variable.context';
import { setCookie } from '@gitroom/frontend/components/layout/layout.context';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { clearSentryUserContext } from '@gitroom/react/sentry/sentry.user.context';
export const LogoutComponent = () => {
const fetch = useFetch();
const { isGeneral, isSecured } = useVariables();
Expand All @@ -21,6 +22,9 @@ export const LogoutComponent = () => {
t('yes_logout', 'Yes logout')
)
) {
// Clear Sentry user context on logout
clearSentryUserContext();

if (!isSecured) {
setCookie('auth', '', -10);
} else {
Expand Down
19 changes: 18 additions & 1 deletion apps/frontend/src/components/layout/user.context.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
'use client';

import { createContext, FC, ReactNode, useContext } from 'react';
import { createContext, FC, ReactNode, useContext, useEffect } from 'react';
import { User } from '@prisma/client';
import {
pricing,
PricingInnerInterface,
} from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { setSentryUserContext } from '@gitroom/react/sentry/sentry.user.context';
export const UserContext = createContext<
| undefined
| (User & {
Expand Down Expand Up @@ -36,6 +37,22 @@ export const ContextWrapper: FC<{
tier: pricing[user.tier],
}
: ({} as any);

// Set Sentry user context whenever user changes
useEffect(() => {
if (user) {
setSentryUserContext({
id: user.id,
email: user.email,
orgId: user.orgId,
role: user.role,
tier: user.tier,
});
} else {
setSentryUserContext(null);
}
}, [user]);

return <UserContext.Provider value={values}>{children}</UserContext.Provider>;
};
export const useUser = () => useContext(UserContext);
63 changes: 63 additions & 0 deletions libraries/nestjs-libraries/src/sentry/sentry.user.context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as Sentry from '@sentry/nestjs';
import { User } from '@prisma/client';

/**
* Sets user context for Sentry for the current request.
* This will include user information in all error reports and events.
* Only executes if Sentry DSN is configured.
*
* @param user - The user object from the database
*/
export const setSentryUserContext = (user: User | null) => {
// Only set context if Sentry is configured
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) {
return;
}

try {
if (!user) {
// Clear user context when no user is present
Sentry.setUser(null);
return;
}

Sentry.setUser({
id: user.id,
email: user.email,
username: user.email, // Use email as username since that's the primary identifier
// Add additional useful context
ip_address: undefined, // Let Sentry auto-detect IP
});

// Also set additional tags for better filtering in Sentry
Sentry.setTag('user.activated', user.activated);
Sentry.setTag('user.provider', user.providerName || 'local');

if (user.isSuperAdmin) {
Sentry.setTag('user.super_admin', true);
}
} catch {
// Silently fail if Sentry throws an error - we don't want to break the app
}
};

/**
* Clears the Sentry user context.
* Useful when logging out or switching users.
* Only executes if Sentry DSN is configured.
*/
export const clearSentryUserContext = () => {
// Only clear context if Sentry is configured
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) {
return;
}

try {
Sentry.setUser(null);
Sentry.setTag('user.activated', null);
Sentry.setTag('user.provider', null);
Sentry.setTag('user.super_admin', null);
} catch {
// Silently fail if Sentry throws an error - we don't want to break the app
}
};
54 changes: 54 additions & 0 deletions libraries/nestjs-libraries/src/sentry/sentry.user.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Request } from 'express';
import { User } from '@prisma/client';
import { setSentryUserContext } from './sentry.user.context';

/**
* Interceptor that automatically sets Sentry user context for all requests.
* This interceptor runs after authentication middleware has set req.user.
*
* Usage Options:
*
* 1. Global interceptor (recommended for APIs with consistent auth):
* In your app.module.ts:
* ```typescript
* import { APP_INTERCEPTOR } from '@nestjs/core';
* import { SentryUserInterceptor } from '@gitroom/nestjs-libraries/sentry/sentry.user.interceptor';
*
* @Module({
* providers: [
* { provide: APP_INTERCEPTOR, useClass: SentryUserInterceptor },
* ],
* })
* export class AppModule {}
* ```
*
* 2. Controller-level (for specific controllers):
* ```typescript
* @UseInterceptors(SentryUserInterceptor)
* @Controller('users')
* export class UsersController {}
* ```
*
* 3. Method-level (for specific routes):
* ```typescript
* @UseInterceptors(SentryUserInterceptor)
* @Get('profile')
* getProfile() {}
* ```
*/
@Injectable()
export class SentryUserInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();

// Get user from request (set by auth middleware)
const user = (request as any).user as User | undefined;

// Set Sentry user context for this request
setSentryUserContext(user || null);

return next.handle();
}
}
75 changes: 75 additions & 0 deletions libraries/react-shared-libraries/src/sentry/sentry.user.context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import * as Sentry from '@sentry/nextjs';

interface UserInfo {
id: string;
email: string;
orgId?: string;
role?: string;
tier?: string;
}

/**
* Sets user context for Sentry in the frontend.
* This will include user information in all error reports and events.
* Only executes if Sentry DSN is configured.
*
* @param user - The user object from the API
*/
export const setSentryUserContext = (user: UserInfo | null) => {
// Only set context if Sentry is configured
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) {
return;
}

try {
if (!user) {
// Clear user context when no user is present
Sentry.setUser(null);
return;
}

Sentry.setUser({
id: user.id,
email: user.email,
username: user.email, // Use email as username since that's the primary identifier
});

// Also set additional tags for better filtering in Sentry
if (user.orgId) {
Sentry.setTag('user.org_id', user.orgId);
}

if (user.role) {
Sentry.setTag('user.role', user.role);
}

if (user.tier) {
Sentry.setTag('user.tier', user.tier);
}
} catch {
// Silently fail if Sentry throws an error - we don't want to break the app
}
};

/**
* Clears the Sentry user context.
* Useful when logging out or switching users.
* Only executes if Sentry DSN is configured.
*/
export const clearSentryUserContext = () => {
// Only clear context if Sentry is configured
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) {
return;
}

try {
Sentry.setUser(null);
Sentry.setTag('user.org_id', null);
Sentry.setTag('user.role', null);
Sentry.setTag('user.tier', null);
} catch {
// Silently fail if Sentry throws an error - we don't want to break the app
}
};