diff --git a/apps/backend/src/api/routes/users.controller.ts b/apps/backend/src/api/routes/users.controller.ts index 601ba6556..2a4e66f2d 100644 --- a/apps/backend/src/api/routes/users.controller.ts +++ b/apps/backend/src/api/routes/users.controller.ts @@ -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') @@ -199,6 +200,9 @@ export class UsersController { @Post('/logout') logout(@Res({ passthrough: true }) response: Response) { + // Clear Sentry user context on logout + clearSentryUserContext(); + response.cookie('auth', '', { domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), ...(!process.env.NOT_SECURED diff --git a/apps/backend/src/services/auth/auth.middleware.ts b/apps/backend/src/services/auth/auth.middleware.ts index 0ef8377ad..f4879d737 100644 --- a/apps/backend/src/services/auth/auth.middleware.ts +++ b/apps/backend/src/services/auth/auth.middleware.ts @@ -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'; export const removeAuth = (res: Response) => { res.cookie('auth', '', { @@ -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 { @@ -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; } @@ -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(); diff --git a/apps/frontend/src/components/layout/logout.component.tsx b/apps/frontend/src/components/layout/logout.component.tsx index 3128f522c..6debe0718 100644 --- a/apps/frontend/src/components/layout/logout.component.tsx +++ b/apps/frontend/src/components/layout/logout.component.tsx @@ -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(); @@ -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 { diff --git a/apps/frontend/src/components/layout/user.context.tsx b/apps/frontend/src/components/layout/user.context.tsx index 9e5ec48d2..8ec59335e 100644 --- a/apps/frontend/src/components/layout/user.context.tsx +++ b/apps/frontend/src/components/layout/user.context.tsx @@ -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 & { @@ -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 {children}; }; export const useUser = () => useContext(UserContext); diff --git a/libraries/nestjs-libraries/src/sentry/sentry.user.context.ts b/libraries/nestjs-libraries/src/sentry/sentry.user.context.ts new file mode 100644 index 000000000..476d163f9 --- /dev/null +++ b/libraries/nestjs-libraries/src/sentry/sentry.user.context.ts @@ -0,0 +1,55 @@ +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; + } + + 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); + } +}; + +/** + * 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; + } + + Sentry.setUser(null); + Sentry.setTag('user.activated', null); + Sentry.setTag('user.provider', null); + Sentry.setTag('user.super_admin', null); +}; diff --git a/libraries/nestjs-libraries/src/sentry/sentry.user.interceptor.ts b/libraries/nestjs-libraries/src/sentry/sentry.user.interceptor.ts new file mode 100644 index 000000000..80f458e1e --- /dev/null +++ b/libraries/nestjs-libraries/src/sentry/sentry.user.interceptor.ts @@ -0,0 +1,24 @@ +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. + */ +@Injectable() +export class SentryUserInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + + // 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(); + } +} diff --git a/libraries/react-shared-libraries/src/sentry/sentry.user.context.ts b/libraries/react-shared-libraries/src/sentry/sentry.user.context.ts new file mode 100644 index 000000000..874f24677 --- /dev/null +++ b/libraries/react-shared-libraries/src/sentry/sentry.user.context.ts @@ -0,0 +1,67 @@ +'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; + } + + 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); + } +}; + +/** + * 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; + } + + Sentry.setUser(null); + Sentry.setTag('user.org_id', ''); + Sentry.setTag('user.role', ''); + Sentry.setTag('user.tier', ''); +};