diff --git a/backend/src/xfd_django/xfd_api/api_methods/domain.py b/backend/src/xfd_django/xfd_api/api_methods/domain.py index 1ddb444cb..db323af91 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/domain.py +++ b/backend/src/xfd_django/xfd_api/api_methods/domain.py @@ -10,7 +10,7 @@ from fastapi import HTTPException from xfd_mini_dl.models import Domain, Service -from ..auth import get_org_memberships, is_global_view_admin +from ..auth import get_org_memberships, is_analytics_user, is_global_view_admin from ..helpers.filter_helpers import apply_domain_filters, sort_direction from ..helpers.s3_client import S3Client from ..schema_models.domain import DomainSearch @@ -98,7 +98,7 @@ def search_domains(domain_search: DomainSearch, current_user): ) # Apply global user permission filters - if not is_global_view_admin(current_user): + if not is_global_view_admin(current_user) | is_analytics_user(current_user): orgs = get_org_memberships(current_user) if not orgs: # No organization memberships, return empty result diff --git a/backend/src/xfd_django/xfd_api/api_methods/organization.py b/backend/src/xfd_django/xfd_api/api_methods/organization.py index 91f5ba93a..a42eaf47d 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/organization.py +++ b/backend/src/xfd_django/xfd_api/api_methods/organization.py @@ -13,6 +13,7 @@ from ..auth import ( get_org_memberships, + is_analytics_user, is_global_view_admin, is_global_write_admin, is_org_admin, @@ -42,14 +43,18 @@ def list_organizations(current_user): """List organizations that the user is a member of or has access to.""" try: # Check if user is GlobalViewAdmin or has memberships - if not is_global_view_admin(current_user) and not get_org_memberships( - current_user + if ( + not is_global_view_admin(current_user) + and not is_analytics_user(current_user) + and not get_org_memberships(current_user) ): return [] # Define filter for organizations based on admin status org_filter = {} - if not is_global_view_admin(current_user): + if not is_global_view_admin(current_user) and not is_analytics_user( + current_user + ): org_filter["id__in"] = get_org_memberships(current_user) org_filter["parent"] = None @@ -128,9 +133,10 @@ def get_organization(organization_id, current_user): try: # Authorization checks if not ( + is_analytics_user(current_user), is_org_admin(current_user, organization_id) or is_global_view_admin(current_user) - or is_regional_admin_for_organization(current_user, organization_id) + or is_regional_admin_for_organization(current_user, organization_id), ): raise HTTPException(status_code=403, detail="Unauthorized") @@ -337,8 +343,10 @@ def get_all_regions(current_user): """Get all regions.""" try: # Check if user is GlobalViewAdmin or has memberships - if not is_global_view_admin(current_user) and not get_org_memberships( - current_user + if ( + not is_global_view_admin(current_user) + and not is_analytics_user(current_user) + and not get_org_memberships(current_user) ): raise HTTPException(status_code=403, detail="Unauthorized") @@ -997,15 +1005,19 @@ def list_organizations_v2(state, region_id, current_user): """List organizations that the user is a member of or has access to.""" try: # Check if user is GlobalViewAdmin or has memberships - if not is_global_view_admin(current_user) and not get_org_memberships( - current_user + if ( + not is_global_view_admin(current_user) + and not is_analytics_user(current_user) + and not get_org_memberships(current_user) ): return [] # Prepare the filter criteria filter_criteria = Q() - if not is_global_view_admin(current_user): + if not is_global_view_admin(current_user) and not is_analytics_user( + current_user + ): filter_criteria &= Q(id__in=get_org_memberships(current_user)) if state: @@ -1066,8 +1078,10 @@ def search_organizations_task(search_body, current_user: User): """Handle the logic for searching organizations in Elasticsearch.""" try: # Check if user is GlobalViewAdmin or has memberships - if not is_global_view_admin(current_user) and not get_org_memberships( - current_user + if ( + not is_global_view_admin(current_user) + and not is_analytics_user(current_user) + and not get_org_memberships(current_user) ): return [] diff --git a/backend/src/xfd_django/xfd_api/api_methods/proxy.py b/backend/src/xfd_django/xfd_api/api_methods/proxy.py index 526655e91..30e30e442 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/proxy.py +++ b/backend/src/xfd_django/xfd_api/api_methods/proxy.py @@ -5,19 +5,10 @@ # Third-Party Libraries from fastapi import Request -from fastapi.responses import Response +from fastapi.responses import RedirectResponse, Response import httpx -# Helper function to handle cookie manipulation -def manipulate_cookie(request: Request, cookie_name: str): - """Manipulate cookie.""" - cookies = request.cookies.get(cookie_name) - if cookies: - return {cookie_name: cookies} - return {} - - # Helper function to proxy requests async def proxy_request( request: Request, @@ -25,17 +16,19 @@ async def proxy_request( path: Optional[str] = None, cookie_name: Optional[str] = None, ): - """Proxy the request to the target URL.""" + """Proxy requests to the specified target URL with optional cookie handling.""" + print("Proxying request to target URL: {}".format(target_url)) headers = dict(request.headers) - # Cookie manipulation for specific cookie names + # Include specified cookie in the headers if present if cookie_name: - cookies = manipulate_cookie(request, cookie_name) + print("Cookie name: {}".format(cookie_name)) + cookies = request.cookies.get(cookie_name) if cookies: - headers["Cookie"] = "{}={}".format(cookie_name, cookies[cookie_name]) + headers["Cookie"] = "{}={}".format(cookie_name, cookies) - # Make the request to the target URL - async with httpx.AsyncClient() as client: + # Send the request to the target + async with httpx.AsyncClient(timeout=httpx.Timeout(90.0)) as client: proxy_response = await client.request( method=request.method, url="{}/{}".format(target_url, path), @@ -43,13 +36,39 @@ async def proxy_request( params=request.query_params, content=await request.body(), ) - - # Remove chunked encoding for API Gateway compatibility + # Adjust response headers proxy_response_headers = dict(proxy_response.headers) - proxy_response_headers.pop("transfer-encoding", None) + for header in ["content-encoding", "transfer-encoding", "content-length"]: + proxy_response_headers.pop(header, None) return Response( content=proxy_response.content, status_code=proxy_response.status_code, headers=proxy_response_headers, ) + + +async def matomo_proxy_handler( + request: Request, + path: str, + MATOMO_URL: str, +): + """ + Handle Matomo-specific proxy logic. + + Includes public paths, font redirects, and authentication for private paths. + """ + # Redirect font requests to CDN + font_paths = { + "/plugins/Morpheus/fonts/matomo.woff2": "https://cdn.jsdelivr.net/gh/matomo-org/matomo@5.2.1/plugins/Morpheus/fonts/matomo.woff2", + "/plugins/Morpheus/fonts/matomo.woff": "https://cdn.jsdelivr.net/gh/matomo-org/matomo@5.2.1/plugins/Morpheus/fonts/matomo.woff", + "/plugins/Morpheus/fonts/matomo.ttf": "https://cdn.jsdelivr.net/gh/matomo-org/matomo@5.2.1/plugins/Morpheus/fonts/matomo.ttf", + } + if path in font_paths: + return RedirectResponse(url=font_paths[path]) + + return await proxy_request( + request=request, + target_url=MATOMO_URL, + path=path, + ) diff --git a/backend/src/xfd_django/xfd_api/api_methods/search.py b/backend/src/xfd_django/xfd_api/api_methods/search.py index 6cb173e97..e6d6103d5 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/search.py +++ b/backend/src/xfd_django/xfd_api/api_methods/search.py @@ -9,6 +9,7 @@ from xfd_api.auth import ( get_org_memberships, get_tag_organizations, + is_analytics_user, is_global_view_admin, ) from xfd_api.helpers.elastic_search import build_request @@ -22,7 +23,7 @@ async def get_options(search_body, user) -> Dict[str, Any]: """Get Elastic Search options.""" if search_body.organization_id and ( search_body.organization_id in get_org_memberships(user) - or is_global_view_admin(user) + or (is_global_view_admin(user) | is_analytics_user(user)) ): return { "organization_ids": [search_body.organization_id], @@ -36,7 +37,7 @@ async def get_options(search_body, user) -> Dict[str, Any]: return { "organization_ids": get_org_memberships(user), - "match_all_organizations": is_global_view_admin(user), + "match_all_organizations": is_global_view_admin(user) | is_analytics_user(user), } diff --git a/backend/src/xfd_django/xfd_api/api_methods/stats.py b/backend/src/xfd_django/xfd_api/api_methods/stats.py index 0e5058f64..e6d4dc5a4 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/stats.py +++ b/backend/src/xfd_django/xfd_api/api_methods/stats.py @@ -21,7 +21,7 @@ VulnScanSummary, ) -from ..auth import get_org_memberships, is_global_view_admin +from ..auth import get_org_memberships, is_analytics_user, is_global_view_admin # GET: /stats @@ -109,7 +109,8 @@ async def safe_fetch(fetch_fn, *args, **kwargs): } except Exception as e: raise HTTPException( - status_code=500, detail="An unexpected error occurred: {}".format(e) + status_code=500, + detail="An unexpected error occurred: {}".format(e), ) @@ -453,7 +454,7 @@ def get_vs_condensed_trending_data(filters, current_user): raise HTTPException(status_code=404, detail="Organization not found.") if ( - not is_global_view_admin(current_user) + not is_global_view_admin(current_user) | is_analytics_user(current_user) and not current_user.user_type == "regionalAdmin" ): org_ids = get_org_memberships(current_user) @@ -538,6 +539,7 @@ def get_vs_trending_data(filters, current_user): if ( not is_global_view_admin(current_user) + and not is_analytics_user(current_user) and not current_user.user_type == "regionalAdmin" ): org_ids = get_org_memberships(current_user) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index fbaafc5e5..c886f9430 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -13,6 +13,7 @@ from ..auth import ( can_access_user, + is_analytics_user, is_global_view_admin, is_global_write_admin, is_org_admin, @@ -193,7 +194,11 @@ def get_users(current_user): """Retrieve a list of all users.""" try: # Check if user is a regional admin or global admin - if not is_global_view_admin(current_user) | is_regional_admin(current_user): + if ( + not is_global_view_admin(current_user) + | is_regional_admin(current_user) + | is_analytics_user(current_user) + ): raise HTTPException(status_code=401, detail="Unauthorized") users = User.objects.all().prefetch_related("roles__organization") @@ -241,7 +246,7 @@ def get_users(current_user): def get_users_by_region_id(region_id, current_user): """List users with specific region_id.""" try: - if not is_regional_admin(current_user): + if not is_regional_admin(current_user) | is_analytics_user(current_user): raise HTTPException(status_code=401, detail="Unauthorized") if not region_id: @@ -357,7 +362,11 @@ def get_users_v2(state, region_id, invite_pending, current_user): """Retrieve a list of users based on optional filter parameters.""" try: # Check if user is a regional admin or global admin - if not is_regional_admin(current_user) | is_global_view_admin(current_user): + if ( + not is_regional_admin(current_user) + | is_global_view_admin(current_user) + | is_analytics_user(current_user) + ): raise HTTPException(status_code=401, detail="Unauthorized") filters = {} diff --git a/backend/src/xfd_django/xfd_api/api_methods/vulnerability.py b/backend/src/xfd_django/xfd_api/api_methods/vulnerability.py index 060131191..801270b78 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/vulnerability.py +++ b/backend/src/xfd_django/xfd_api/api_methods/vulnerability.py @@ -23,7 +23,7 @@ Vulnerability, ) -from ..auth import get_org_memberships, is_global_view_admin +from ..auth import get_org_memberships, is_analytics_user, is_global_view_admin from ..helpers.filter_helpers import apply_vuln_filters, sort_direction from ..helpers.s3_client import S3Client from ..models import Domain @@ -105,6 +105,7 @@ def get_vulnerability_by_scan_source_and_id( if ( not is_global_view_admin(current_user) + and not is_analytics_user(current_user) and not current_user.user_type == "regionalAdmin" ): org_ids = get_org_memberships(current_user) @@ -385,6 +386,7 @@ def search_vulnerabilities(vulnerability_search: VulnerabilitySearch, current_us # Permissions check if ( not is_global_view_admin(current_user) + and not is_analytics_user(current_user) and not current_user.user_type == "regionalAdmin" ): org_ids = get_org_memberships(current_user) diff --git a/backend/src/xfd_django/xfd_api/auth.py b/backend/src/xfd_django/xfd_api/auth.py index 67b2c3f67..e12691a84 100644 --- a/backend/src/xfd_django/xfd_api/auth.py +++ b/backend/src/xfd_django/xfd_api/auth.py @@ -338,6 +338,11 @@ def is_regional_admin(current_user) -> bool: return current_user and current_user.user_type in ["regionalAdmin", "globalAdmin"] +def is_analytics_user(current_user) -> bool: + """Check if the user has analytics permissions.""" + return current_user and current_user.user_type in ["analytics", "globalAdmin"] + + def is_org_admin(current_user, organization_id) -> bool: """Check if the user is an admin of the given organization.""" if not organization_id: @@ -461,6 +466,7 @@ def get_stats_org_ids(current_user, filters): or (is_regional_admin_for_organization(current_user, org_id)) or (is_org_admin(current_user, org_id)) or (get_org_memberships(current_user)) + or (is_analytics_user(current_user)) ): organization_ids.add(org_id) @@ -483,11 +489,21 @@ def get_stats_org_ids(current_user, filters): for tag_id in tags_filter: organizations_by_tag = get_tag_organizations(current_user, tag_id) organization_ids.update(organizations_by_tag) - - # Case 3: Regional admin + # Case 3: Analytics view + elif is_analytics_user(current_user): + # Get organizations by region + if regions_filter: + organizations_by_region = Organization.objects.filter( + region_id__in=regions_filter + ).values_list("id", flat=True) + organization_ids.update(organizations_by_region) + # Get organizations by tag + for tag_id in tags_filter: + organizations_by_tag = get_tag_organizations(current_user, tag_id) + organization_ids.update(organizations_by_tag) + # Case 4: Regional admin elif current_user.user_type in ["regionalAdmin"]: user_region_id = current_user.region_id - # Allow only organizations in the user's region organizations_in_region = Organization.objects.filter( region_id=user_region_id @@ -508,7 +524,7 @@ def get_stats_org_ids(current_user, filters): ] organization_ids.update(regional_tag_organizations) - # Case 4: Standard user + # Case 5: Standard user else: # Allow only organizations where the user is a member user_organization_ids = current_user.roles.values_list( diff --git a/backend/src/xfd_django/xfd_api/models.py b/backend/src/xfd_django/xfd_api/models.py index 47f63272c..df8286047 100644 --- a/backend/src/xfd_django/xfd_api/models.py +++ b/backend/src/xfd_django/xfd_api/models.py @@ -533,6 +533,7 @@ class Meta: class UserType(models.TextChoices): """User type definition.""" + ANALYTICS = "analytics" GLOBAL_ADMIN = "globalAdmin" GLOBAL_VIEW = "globalView" REGIONAL_ADMIN = "regionalAdmin" diff --git a/backend/src/xfd_django/xfd_api/schema_models/user.py b/backend/src/xfd_django/xfd_api/schema_models/user.py index 0e9f6e3f9..aac89a8fc 100644 --- a/backend/src/xfd_django/xfd_api/schema_models/user.py +++ b/backend/src/xfd_django/xfd_api/schema_models/user.py @@ -16,6 +16,7 @@ class UserType(Enum): """User Type.""" + ANALYTICS = "analytics" GLOBAL_ADMIN = "globalAdmin" GLOBAL_VIEW = "globalView" REGIONAL_ADMIN = "regionalAdmin" diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 41a15f661..c635dff9b 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -143,43 +143,35 @@ async def get_redis_client(request: Request): # ======================================== +# Matomo Logo Redirect +@api_router.get("/plugins/Morpheus/images/logo.svg") +async def redirect_logo(): + """Redirect to the Matomo logo.""" + return RedirectResponse( + url="/matomo/plugins/Morpheus/images/logo.svg?matomo", status_code=308 + ) + + +# Matomo Index Redirect +@api_router.get("/index.php") +async def redirect_index(): + """Redirect to the Matomo index page.""" + return RedirectResponse(url="/matomo/index.php", status_code=308) + + # Matomo Proxy @api_router.api_route( "/matomo/{path:path}", - dependencies=[Depends(get_current_active_user)], + methods=["GET", "POST", "PUT", "DELETE"], tags=["Analytics"], ) async def matomo_proxy( - path: str, request: Request, current_user: User = Depends(get_current_active_user) + path: str, + request: Request, ): """Proxy requests to the Matomo analytics instance.""" - # Public paths -- directly allowed - allowed_paths = ["/matomo.php", "/matomo.js"] - if any( - [request.url.path.startswith(allowed_path) for allowed_path in allowed_paths] - ): - return await proxy.proxy_request(path, request, os.getenv("MATOMO_URL")) - - # Redirects for specific font files - if request.url.path in [ - "/plugins/Morpheus/fonts/matomo.woff2", - "/plugins/Morpheus/fonts/matomo.woff", - "/plugins/Morpheus/fonts/matomo.ttf", - ]: - return RedirectResponse( - url="https://cdn.jsdelivr.net/gh/matomo-org/matomo@5.2.1{}".format( - request.url.path - ) - ) - - # Ensure only global admin can access other paths - if current_user.user_type != "globalAdmin": - raise HTTPException(status_code=403, detail="Unauthorized") - - # Handle the proxy request to Matomo - return await proxy.proxy_request( - request, os.getenv("MATOMO_URL", ""), path, cookie_name="MATOMO_SESSID" - ) + MATOMO_URL = os.getenv("MATOMO_URL", "") + return await proxy.matomo_proxy_handler(request, path, MATOMO_URL) # P&E Proxy @@ -190,15 +182,19 @@ async def matomo_proxy( tags=["Analytics"], ) async def pe_proxy( - path: str, request: Request, current_user: User = Depends(get_current_active_user) + path: str, + request: Request, + current_user: UserSchema = Depends(get_current_active_user), ): """Proxy requests to the P&E Django application.""" + PE_API_URL = os.getenv("PE_API_URL", "") + # Ensure only Global Admin and Global View users can access if current_user.user_type not in ["globalView", "globalAdmin"]: raise HTTPException(status_code=403, detail="Unauthorized") - # Handle the proxy request to the P&E Django application - return await proxy.proxy_request(request, os.getenv("PE_API_URL", ""), path) + # Proxy the request to the P&E Django application + return await proxy.proxy_request(request, PE_API_URL, path) # ======================================== diff --git a/backend/src/xfd_django/xfd_django/asgi.py b/backend/src/xfd_django/xfd_django/asgi.py index 84c527ac2..339ccc85f 100644 --- a/backend/src/xfd_django/xfd_django/asgi.py +++ b/backend/src/xfd_django/xfd_django/asgi.py @@ -36,9 +36,22 @@ apps.populate(settings.INSTALLED_APPS) -def set_security_headers(response: Response): +def set_security_headers(response: Response, isMatomo=False): """Apply security headers to the HTTP response.""" - # Set Content Security Policy (CSP) + # Conditionally set Content Security Policy (CSP) based on the request URL + + # TODO: Uncomment the following lines if you want to set CSP for Matomo + # if isMatomo: + # csp_value = "; ".join( + # [ + # "{} {}".format(key, " ".join(map(str, value))) + # for key, value in settings.MATOMO_CSP_POLICY.items() + # if isinstance(value, (list, tuple)) + # ] + # ) + # response.headers["Content-Security-Policy"] = csp_value + # else: + # Set Secure Content Security Policy (CSP) csp_value = "; ".join( [ "{} {}".format(key, " ".join(map(str, value))) @@ -90,7 +103,10 @@ def get_application() -> FastAPI: @app.middleware("http") async def security_headers_middleware(request: Request, call_next): response = await call_next(request) - return set_security_headers(response) + isMatomo = False # Replace with actual condition to check if it's Matomo + # Uncomment to conditionally set CSP for Matomo + # isMatomo = True if request.url.path.startswith("/matomo") else False + return set_security_headers(response, isMatomo) app.add_middleware(LoggingMiddleware) diff --git a/backend/src/xfd_django/xfd_django/settings.py b/backend/src/xfd_django/xfd_django/settings.py index 2249ced4b..03e49594c 100644 --- a/backend/src/xfd_django/xfd_django/settings.py +++ b/backend/src/xfd_django/xfd_django/settings.py @@ -159,6 +159,16 @@ SESSION_COOKIE_HTTPONLY = True CSRF_COOKIE_HTTPONLY = True +# Awaiting implementation of Matomo CSP, uncomment when ready +# MATOMO_CSP_POLICY = { +# "default-src": ["*", "'unsafe-inline'", "'unsafe-eval'"], +# "connect-src": ["*"], +# "img-src": ["*"], +# "style-src": ["*", "'unsafe-inline'"], +# "frame-ancestors": ["*"], +# "frame-src": ["*"], +# } + # SameSite policy to prevent CSRF via cross-origin requests SESSION_COOKIE_SAMESITE = "Lax" CSRF_COOKIE_SAMESITE = "Lax" @@ -201,7 +211,6 @@ "https://www.ssa.gov/accessibility/andi/fandi.js", "https://www.ssa.gov/accessibility/andi/andi.js", "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.9.0/swagger-ui-bundle.js", - "'sha256-QOOQu4W1oxGqd2nbXbxiA1Di6OHQOLQD+o+G9oWL8YY='", "https://www.dhs.gov", ], "style-src": [ diff --git a/docker-compose.yml b/docker-compose.yml index 7f84d1c87..e66b8510d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,7 +131,7 @@ services: environment: - MYSQL_ROOT_PASSWORD=password logging: - driver: none + driver: json-file ports: - 3306:3306 @@ -153,7 +153,7 @@ services: - MATOMO_GENERAL_PROXY_URI_HEADER=1 - MATOMO_GENERAL_ASSUME_SECURE_PROTOCOL=1 logging: - driver: none + driver: json-file ports: - "8080:80" diff --git a/frontend/public/index.html b/frontend/public/index.html index d5ffb026e..3913af945 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -8,23 +8,8 @@ CyHy Dashboard - + + diff --git a/frontend/public/js/tracking.js b/frontend/public/js/tracking.js new file mode 100644 index 000000000..d2905d4ee --- /dev/null +++ b/frontend/public/js/tracking.js @@ -0,0 +1,8 @@ +var idSite = 1; +var matomoTrackingApiUrl = 'http://localhost:3000/matomo/matomo.php'; + +var _paq = (window._paq = window._paq || []); +_paq.push(['setTrackerUrl', matomoTrackingApiUrl]); +_paq.push(['setSiteId', idSite]); +_paq.push(['trackPageView']); +_paq.push(['enableLinkTracking']); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 10100e297..509489b92 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -125,6 +125,7 @@ const App: React.FC = () => ( path="/inventory" component={SearchPage} permissions={[ + 'analytics', 'globalView', 'regionalAdmin', 'standard' @@ -134,6 +135,7 @@ const App: React.FC = () => ( path="/inventory/domain/:domainId" component={Domain} permissions={[ + 'analytics', 'globalView', 'regionalAdmin', 'standard' @@ -152,6 +154,7 @@ const App: React.FC = () => ( exact component={Vulnerabilities} permissions={[ + 'analytics', 'globalView', 'regionalAdmin', 'standard' @@ -163,6 +166,7 @@ const App: React.FC = () => ( )} permissions={[ + 'analytics', 'globalView', 'regionalAdmin', 'standard' @@ -172,6 +176,7 @@ const App: React.FC = () => ( path="/inventory/vulnerability/:vulnerabilityId" component={Vulnerability} permissions={[ + 'analytics', 'globalView', 'regionalAdmin', 'standard' @@ -186,6 +191,7 @@ const App: React.FC = () => ( path="/reports" component={Reports} permissions={[ + 'analytics', 'globalView', 'regionalAdmin', 'standard' @@ -198,12 +204,17 @@ const App: React.FC = () => ( ( ( diff --git a/frontend/src/hooks/useUserLevel.ts b/frontend/src/hooks/useUserLevel.ts index d07cf7564..5bafa38c4 100644 --- a/frontend/src/hooks/useUserLevel.ts +++ b/frontend/src/hooks/useUserLevel.ts @@ -2,9 +2,11 @@ import { AuthContextType, useAuthContext } from 'context'; export const GLOBAL_ADMIN = 3; export const REGIONAL_ADMIN = 2; +export const ANALYTICS = 2; export const STANDARD_USER = 1; type UserType = + | 'analytics' | 'standard' | 'globalAdmin' | 'regionalAdmin' @@ -37,6 +39,9 @@ export const useUserLevel: () => UserLevel = () => { } else if (user.user_type === 'globalView') { userLevel = REGIONAL_ADMIN; formattedUserType = 'Global View'; + } else if (user.user_type === 'analytics') { + userLevel = REGIONAL_ADMIN; + formattedUserType = 'Analytics'; } } return { diff --git a/frontend/src/hooks/useUserTypeFilters.ts b/frontend/src/hooks/useUserTypeFilters.ts index 1617c5a53..365864d1c 100644 --- a/frontend/src/hooks/useUserTypeFilters.ts +++ b/frontend/src/hooks/useUserTypeFilters.ts @@ -1,5 +1,10 @@ import { AuthContextType } from 'context'; -import { GLOBAL_ADMIN, REGIONAL_ADMIN, STANDARD_USER } from './useUserLevel'; +import { + ANALYTICS, + GLOBAL_ADMIN, + REGIONAL_ADMIN, + STANDARD_USER +} from './useUserLevel'; import { GLOBAL_VIEW } from 'context/userStateUtils'; import { OrganizationShallow } from 'components/RegionAndOrganizationFilters'; @@ -105,7 +110,19 @@ export const useUserTypeFilters: UseUserTypeFilters = ( type: 'any' } ]; - + case ANALYTICS: + return [ + { + field: 'organization.regionId', + values: regions, + type: 'any' + }, + { + field: 'organizationId', + values: userOrgs, + type: 'any' + } + ]; default: return []; break; diff --git a/frontend/src/pages/Organization/OrgSettings.tsx b/frontend/src/pages/Organization/OrgSettings.tsx index b3a07e1b5..9d788d92c 100644 --- a/frontend/src/pages/Organization/OrgSettings.tsx +++ b/frontend/src/pages/Organization/OrgSettings.tsx @@ -62,6 +62,8 @@ export const OrgSettings: React.FC = ({ const [chosenTags, setChosenTags] = React.useState( organization.tags ? organization.tags.map((tag) => tag.name) : [] ); + const disableButton = + user?.user_type === 'globalView' || user?.user_type === 'analytics'; const updateOrganization = async (body: any) => { try { @@ -179,7 +181,7 @@ export const OrgSettings: React.FC = ({ } setIsSaveDisabled(false); }} - disabled={user?.user_type === 'globalView'} + disabled={disableButton} > ))} @@ -202,7 +204,7 @@ export const OrgSettings: React.FC = ({ stage: 1 }); }} - disabled={user?.user_type === 'globalView'} + disabled={disableButton} > ))} @@ -221,7 +223,7 @@ export const OrgSettings: React.FC = ({ stage: 0 }); }} - disabled={user?.user_type === 'globalView'} + disabled={disableButton} /> )} @@ -459,7 +461,7 @@ export const OrgSettings: React.FC = ({ } }} color="primary" - disabled={user?.user_type === 'globalView'} + disabled={disableButton} /> @@ -481,7 +483,7 @@ export const OrgSettings: React.FC = ({ disabled={ organization.root_domains.length === 0 || isSaveDisabled || - user?.user_type === 'globalView' + disableButton } > Save diff --git a/frontend/src/pages/RegionUsers/RegionUsers.tsx b/frontend/src/pages/RegionUsers/RegionUsers.tsx index a840df43b..9e16f816e 100644 --- a/frontend/src/pages/RegionUsers/RegionUsers.tsx +++ b/frontend/src/pages/RegionUsers/RegionUsers.tsx @@ -52,6 +52,8 @@ export const RegionUsers: React.FC = () => { const { formattedUserType } = useUserLevel(); const getOrgsURL = `/organizations/region_id/`; const getUsersURL = `/v2/users?invite_pending=`; + const disableButton = + user?.user_type === 'globalView' || user?.user_type === 'analytics'; const pendingCols: GridColDef[] = [ { field: 'full_name', headerName: 'Name', minWidth: 100, flex: 1 }, { field: 'email', headerName: 'Email', minWidth: 100, flex: 2 }, @@ -70,7 +72,7 @@ export const RegionUsers: React.FC = () => { endIcon={} color="success" onClick={() => handleApproveClick(cellValues.row)} - disabled={user?.user_type === 'globalView'} + disabled={disableButton} > Approve @@ -79,7 +81,7 @@ export const RegionUsers: React.FC = () => { endIcon={} color="error" onClick={() => handleDenyClick(cellValues.row)} - disabled={user?.user_type === 'globalView'} + disabled={disableButton} > Deny diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index cdd98d9ee..7ed21b482 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -20,6 +20,20 @@ const Settings: React.FC = () => { .join(', ')}

Region: {user && user.region_id ? user.region_id : 'None'}

+ {user?.user_type === 'analytics' && ( + <> + +
+
+ + )} diff --git a/frontend/src/pages/Users/UserForm.tsx b/frontend/src/pages/Users/UserForm.tsx index 043d54aea..3246f1986 100644 --- a/frontend/src/pages/Users/UserForm.tsx +++ b/frontend/src/pages/Users/UserForm.tsx @@ -490,6 +490,12 @@ export const UserForm: React.FC = ({ label="Global Administrator" disabled={user?.user_type !== 'globalAdmin'} /> + } + label="Analytics" + disabled={user?.user_type !== 'globalAdmin'} + /> {formErrors.user_type && ( diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 2dd974d1a..993d30cbc 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -9,7 +9,12 @@ export interface User { last_name: string; full_name: string; invite_pending: boolean; - user_type: 'standard' | 'globalView' | 'globalAdmin' | 'regionalAdmin'; + user_type: + | 'standard' + | 'globalView' + | 'globalAdmin' + | 'regionalAdmin' + | 'analytics'; email: string; roles: Role[]; date_accepted_terms: string | null; @@ -49,7 +54,12 @@ export type UserFormValues = { first_name: string; last_name: string; email: string; - user_type: 'standard' | 'globalView' | 'globalAdmin' | 'regionalAdmin'; + user_type: + | 'standard' + | 'globalView' + | 'globalAdmin' + | 'regionalAdmin' + | 'analytics'; state: string; region_id: string; org_name: string;