@cryptotaxi247 / CoPilot / commits / 5afada7b

feat(customer-portal): multi-customer filter to scope portal data (#890)

* feat(customer-portal): multi-customer filter to scope portal data (#873) Users assigned to multiple customers had no way to focus the portal on a specific customer or subset. This adds a multi-select filter in the portal header that scopes the dashboard stats and the alerts/cases lists to the selected customers (empty = all accessible, preserving prior behaviour). Backend (optional, access-preserving): - CustomerAccessHandler.resolve_effective_customers() intersects a requested customer subset with the user's accessible set; a scoped user can never request a customer they lack access to (empty intersection -> no rows). - filter_query_by_customer_access() gains an optional requested_customers arg. - Optional `customer_codes` query param threaded through the dashboard stats endpoints and the main /alerts and /cases list endpoints (+ their *_for_user totals). The param is optional, so the analyst frontend is unaffected. Customer portal: - Auth store/types now expose all JWT customer_codes (not just the first). - New persisted `customer-filter` Pinia store holds the selection; cleared on logout and pruned to the user's accessible codes on mount. - Header CustomerFilter selector (shown only when the user has >1 customer). - withCustomerCodes() API helper serializes the subset as repeated customer_codes params (FastAPI List[str] shape). - Overview (stats + recent alerts/cases) and the alerts/cases list views pass the selection and refetch when it changes. Scope: this slice covers the dashboard and the primary alerts/cases lists. The status/source/assigned-to sub-filter endpoints are not yet customer-scoped and are a documented follow-up. Also removes a pre-existing unused NEllipsis import in alerts/List.vue that was failing type-check. Refs #873 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(customer-portal): scope Alerts/Cases page stat cards to the customer filter The dedicated Alerts and Cases pages render their own stat-card components (AlertsOverviewStatsCards / CasesOverviewStatsCards) separate from the Overview dashboard's cards. These were still calling alertsStats()/casesStats() without the selected customer codes and without a watch, so their counts showed the full accessible total (e.g. 30) regardless of the filter while the list table below was correctly scoped (e.g. 6). Pass customerFilterStore.queryCustomerCodes to the stats calls and refetch when the selection changes, matching OverviewStatsCards. Refs #873 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(customer-portal): apply customer filter to default cases/alerts list path The cases list's default (no sub-filter) branch called getCases() without the selected customer codes, so the Cases table returned every accessible customer's cases instead of the selected subset (the stat cards were already scoped). Pass customerFilterStore.queryCustomerCodes on that path, and do the same for the alerts switch-default fallback for consistency. Refs #873 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

taylor_socfortress committed May 27, 2026 at 15:56 UTC 5afada7b4a1e89fde2a4b5b79ae8c649972a66c2
19 files changed +308 -69
backend/app/customer_portal/routes/dashboard.py
+10 -3
@@ -1,5 +1,9 @@
1 +from typing import List
2 +from typing import Optional
3 +
4 from fastapi import APIRouter
5 from fastapi import Depends
6 +from fastapi import Query
7 from loguru import logger
8 from sqlalchemy import func
9 from sqlalchemy import select
@@ -24,11 +28,12 @@ customer_portal_dashboard_router = APIRouter()
28 response_model=CustomerDashboardStatsResponse,
29 )
30 async def get_customer_dashboard_stats(
31 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the stats to"),
32 current_user: User = Depends(AuthHandler().get_current_user),
33 db: AsyncSession = Depends(get_db),
34 ):
35 """Get total alerts, cases, and agents for the logged-in customer."""
31 - accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
36 + accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
37 logger.info(f"Fetching dashboard stats for user {current_user.username}, customers: {accessible_customers}")
38
39 if "*" in accessible_customers:
@@ -58,11 +63,12 @@ async def get_customer_dashboard_stats(
63 response_model=CustomerDashboardAlertStatsResponse,
64 )
65 async def get_customer_dashboard_alert_stats(
66 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the stats to"),
67 current_user: User = Depends(AuthHandler().get_current_user),
68 db: AsyncSession = Depends(get_db),
69 ):
70 """Get alert counts (total, open, in-progress, closed) for the logged-in customer."""
65 - accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
71 + accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
72 logger.info(f"Fetching dashboard alert stats for user {current_user.username}, customers: {accessible_customers}")
73
74 if "*" in accessible_customers:
@@ -97,11 +103,12 @@ async def get_customer_dashboard_alert_stats(
103 response_model=CustomerDashboardCaseStatsResponse,
104 )
105 async def get_customer_dashboard_case_stats(
106 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the stats to"),
107 current_user: User = Depends(AuthHandler().get_current_user),
108 db: AsyncSession = Depends(get_db),
109 ):
110 """Get case counts (total, open, in-progress, closed) for the logged-in customer."""
104 - accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
111 + accessible_customers = await customer_access_handler.resolve_effective_customers(current_user, customer_codes, db)
112 logger.info(f"Fetching dashboard case stats for user {current_user.username}, customers: {accessible_customers}")
113
114 if "*" in accessible_customers:
backend/app/incidents/routes/db_operations.py
+12 -10
@@ -1206,19 +1206,20 @@ async def list_alerts_endpoint(
1206 page: int = Query(1, ge=1),
1207 page_size: int = Query(25, ge=1),
1208 order: str = Query("desc", pattern="^(asc|desc)$"),
1209 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1210 current_user: User = Depends(AuthHandler().get_current_user),
1211 db: AsyncSession = Depends(get_db),
1212 ):
1213 """List alerts with automatic customer and tag filtering"""
1214 logger.info(f"Listing alerts for user: {current_user.username} with role_id: {current_user.role_id}")
1215
1215 - alerts = await list_alerts_for_user(current_user, db, page, page_size, order)
1216 + alerts = await list_alerts_for_user(current_user, db, page, page_size, order, customer_codes=customer_codes)
1217
1218 # Get totals with both customer and tag filtering
1218 - total = await alert_total_for_user(current_user, db)
1219 - open_alerts = await alerts_open_for_user(current_user, db)
1220 - in_progress = await alerts_in_progress_for_user(current_user, db)
1221 - closed = await alerts_closed_for_user(current_user, db)
1219 + total = await alert_total_for_user(current_user, db, customer_codes=customer_codes)
1220 + open_alerts = await alerts_open_for_user(current_user, db, customer_codes=customer_codes)
1221 + in_progress = await alerts_in_progress_for_user(current_user, db, customer_codes=customer_codes)
1222 + closed = await alerts_closed_for_user(current_user, db, customer_codes=customer_codes)
1223
1224 return AlertOutResponse(
1225 alerts=alerts,
@@ -1781,18 +1782,19 @@ async def list_cases_endpoint(
1782 page: int = Query(1, ge=1),
1783 page_size: int = Query(25, ge=1),
1784 order: str = Query("desc", pattern="^(asc|desc)$"),
1785 + customer_codes: Optional[List[str]] = Query(None, description="Optional subset of customer codes to scope the results to"),
1786 current_user: User = Depends(AuthHandler().get_current_user),
1787 db: AsyncSession = Depends(get_db),
1788 ):
1789 """List cases with automatic customer filtering and pagination"""
1790 logger.info(f"Listing cases for user: {current_user.username} with role_id: {current_user.role_id}")
1791
1790 - cases = await list_cases_for_user(current_user, db, page, page_size, order)
1792 + cases = await list_cases_for_user(current_user, db, page, page_size, order, customer_codes=customer_codes)
1793
1792 - total = await case_total_for_user(current_user, db)
1793 - open_cases = await cases_open_for_user(current_user, db)
1794 - in_progress = await cases_in_progress_for_user(current_user, db)
1795 - closed = await cases_closed_for_user(current_user, db)
1794 + total = await case_total_for_user(current_user, db, customer_codes=customer_codes)
1795 + open_cases = await cases_open_for_user(current_user, db, customer_codes=customer_codes)
1796 + in_progress = await cases_in_progress_for_user(current_user, db, customer_codes=customer_codes)
1797 + closed = await cases_closed_for_user(current_user, db, customer_codes=customer_codes)
1798
1799 return CaseOutResponse(
1800 cases=cases,
backend/app/incidents/services/db_operations.py
+27 -19
@@ -244,7 +244,7 @@ async def alerts_open_by_customer_codes(db: AsyncSession, customer_codes: List[s
244 return len(result.scalars().all())
245
246
247 -async def alert_total_for_user(user: User, db: AsyncSession) -> int:
247 +async def alert_total_for_user(user: User, db: AsyncSession, customer_codes: Optional[List[str]] = None) -> int:
248 """Get total alerts count with customer and tag filtering"""
249 from sqlalchemy import and_
250 from sqlalchemy import exists
@@ -253,7 +253,7 @@ async def alert_total_for_user(user: User, db: AsyncSession) -> int:
253 filters = []
254
255 # Customer filtering
256 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, db)
256 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, db)
257 if "*" not in accessible_customers:
258 filters.append(Alert.customer_code.in_(accessible_customers))
259
@@ -290,7 +290,7 @@ async def alert_total_for_user(user: User, db: AsyncSession) -> int:
290 return result.scalar_one()
291
292
293 -async def alerts_open_for_user(user: User, db: AsyncSession) -> int:
293 +async def alerts_open_for_user(user: User, db: AsyncSession, customer_codes: Optional[List[str]] = None) -> int:
294 """Get open alerts count with customer and tag filtering"""
295 from sqlalchemy import and_
296 from sqlalchemy import exists
@@ -299,7 +299,7 @@ async def alerts_open_for_user(user: User, db: AsyncSession) -> int:
299 filters = [Alert.status == "OPEN"]
300
301 # Customer filtering
302 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, db)
302 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, db)
303 if "*" not in accessible_customers:
304 filters.append(Alert.customer_code.in_(accessible_customers))
305
@@ -336,7 +336,7 @@ async def alerts_open_for_user(user: User, db: AsyncSession) -> int:
336 return result.scalar_one()
337
338
339 -async def alerts_in_progress_for_user(user: User, db: AsyncSession) -> int:
339 +async def alerts_in_progress_for_user(user: User, db: AsyncSession, customer_codes: Optional[List[str]] = None) -> int:
340 """Get in-progress alerts count with customer and tag filtering"""
341 from sqlalchemy import and_
342 from sqlalchemy import exists
@@ -345,7 +345,7 @@ async def alerts_in_progress_for_user(user: User, db: AsyncSession) -> int:
345 filters = [Alert.status == "IN_PROGRESS"]
346
347 # Customer filtering
348 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, db)
348 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, db)
349 if "*" not in accessible_customers:
350 filters.append(Alert.customer_code.in_(accessible_customers))
351
@@ -382,7 +382,7 @@ async def alerts_in_progress_for_user(user: User, db: AsyncSession) -> int:
382 return result.scalar_one()
383
384
385 -async def alerts_closed_for_user(user: User, db: AsyncSession) -> int:
385 +async def alerts_closed_for_user(user: User, db: AsyncSession, customer_codes: Optional[List[str]] = None) -> int:
386 """Get closed alerts count with customer and tag filtering"""
387 from sqlalchemy import and_
388 from sqlalchemy import exists
@@ -391,7 +391,7 @@ async def alerts_closed_for_user(user: User, db: AsyncSession) -> int:
391 filters = [Alert.status == "CLOSED"]
392
393 # Customer filtering
394 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, db)
394 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, db)
395 if "*" not in accessible_customers:
396 filters.append(Alert.customer_code.in_(accessible_customers))
397
@@ -2596,6 +2596,7 @@ async def list_alerts_for_user(
2596 page: int = 1,
2597 page_size: int = 25,
2598 order: str = "desc",
2599 + customer_codes: Optional[List[str]] = None,
2600 ) -> List[AlertOut]:
2601 """List alerts filtered by user's customer access and tag access"""
2602 from sqlalchemy import and_
@@ -2617,7 +2618,7 @@ async def list_alerts_for_user(
2618 filters = []
2619
2620 # 1. Apply customer filtering
2620 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, session)
2621 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, session)
2622 if "*" not in accessible_customers:
2623 filters.append(Alert.customer_code.in_(accessible_customers))
2624
@@ -2694,11 +2695,11 @@ async def list_alerts_for_user(
2695 return alerts_out
2696
2697
2697 -async def case_total_for_user(user: User, session: AsyncSession) -> int:
2698 +async def case_total_for_user(user: User, session: AsyncSession, customer_codes: Optional[List[str]] = None) -> int:
2699 """Get total cases count with customer filtering"""
2700 base_query = select(func.count(Case.id))
2701
2701 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, session)
2702 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, session)
2703 if "*" not in accessible_customers:
2704 base_query = base_query.where(Case.customer_code.in_(accessible_customers))
2705
@@ -2706,11 +2707,11 @@ async def case_total_for_user(user: User, session: AsyncSession) -> int:
2707 return result.scalar_one()
2708
2709
2709 -async def cases_open_for_user(user: User, session: AsyncSession) -> int:
2710 +async def cases_open_for_user(user: User, session: AsyncSession, customer_codes: Optional[List[str]] = None) -> int:
2711 """Get open cases count with customer filtering"""
2712 base_query = select(func.count(Case.id)).where(Case.case_status == "OPEN")
2713
2713 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, session)
2714 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, session)
2715 if "*" not in accessible_customers:
2716 base_query = base_query.where(Case.customer_code.in_(accessible_customers))
2717
@@ -2718,11 +2719,11 @@ async def cases_open_for_user(user: User, session: AsyncSession) -> int:
2719 return result.scalar_one()
2720
2721
2721 -async def cases_in_progress_for_user(user: User, session: AsyncSession) -> int:
2722 +async def cases_in_progress_for_user(user: User, session: AsyncSession, customer_codes: Optional[List[str]] = None) -> int:
2723 """Get in-progress cases count with customer filtering"""
2724 base_query = select(func.count(Case.id)).where(Case.case_status == "IN_PROGRESS")
2725
2725 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, session)
2726 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, session)
2727 if "*" not in accessible_customers:
2728 base_query = base_query.where(Case.customer_code.in_(accessible_customers))
2729
@@ -2730,11 +2731,11 @@ async def cases_in_progress_for_user(user: User, session: AsyncSession) -> int:
2731 return result.scalar_one()
2732
2733
2733 -async def cases_closed_for_user(user: User, session: AsyncSession) -> int:
2734 +async def cases_closed_for_user(user: User, session: AsyncSession, customer_codes: Optional[List[str]] = None) -> int:
2735 """Get closed cases count with customer filtering"""
2736 base_query = select(func.count(Case.id)).where(Case.case_status == "CLOSED")
2737
2737 - accessible_customers = await customer_access_handler.get_user_accessible_customers(user, session)
2738 + accessible_customers = await customer_access_handler.resolve_effective_customers(user, customer_codes, session)
2739 if "*" not in accessible_customers:
2740 base_query = base_query.where(Case.customer_code.in_(accessible_customers))
2741
@@ -2748,6 +2749,7 @@ async def list_cases_for_user(
2749 page: int = 1,
2750 page_size: int = 25,
2751 order: str = "desc",
2752 + customer_codes: Optional[List[str]] = None,
2753 ) -> List[CaseOut]:
2754 """List cases filtered by user's customer access with pagination"""
2755
@@ -2763,8 +2765,14 @@ async def list_cases_for_user(
2765 selectinload(Case.comments),
2766 )
2767
2766 - # Apply customer filtering
2767 - filtered_query = await customer_access_handler.filter_query_by_customer_access(user, session, base_query, Case.customer_code)
2768 + # Apply customer filtering (optionally narrowed to a requested subset)
2769 + filtered_query = await customer_access_handler.filter_query_by_customer_access(
2770 + user,
2771 + session,
2772 + base_query,
2773 + Case.customer_code,
2774 + requested_customers=customer_codes,
2775 + )
2776
2777 # Apply ordering and pagination
2778 final_query = filtered_query.order_by(order_by).offset(offset).limit(page_size)
backend/app/middleware/customer_access.py
+48 -5
@@ -39,19 +39,62 @@ class CustomerAccessHandler:
39 # Specific customer access
40 return customer_code in accessible_customers
41
42 - async def filter_query_by_customer_access(self, user: User, session: AsyncSession, base_query, customer_code_field):
43 - """Filter any query by user's customer access"""
42 + async def resolve_effective_customers(
43 + self,
44 + user: User,
45 + requested_customers: Optional[List[str]],
46 + session: AsyncSession,
47 + ) -> List[str]:
48 + """Resolve the customer codes a query should be filtered to.
49 +
50 + Combines the user's *accessible* customers with an optional *requested*
51 + subset (e.g. a portal customer filter), so a caller can narrow the view
52 + without ever escaping their own access scope.
53 +
54 + Returns either:
55 + - ``["*"]`` — no filtering needed (wildcard access and no requested subset), or
56 + - a concrete list of customer codes to filter on. An empty list means the
57 + requested subset resolved to nothing the user may see, and callers should
58 + treat it as "match no rows" (``column.in_([])``).
59 + """
60 accessible_customers = await self.get_user_accessible_customers(user, session)
61
46 - # Admin/analyst see everything
62 + # No subset requested -> preserve existing behaviour (may be ["*"]).
63 + if not requested_customers:
64 + return accessible_customers
65 +
66 + # Wildcard access (admin/analyst): any requested subset is allowed as-is.
67 + if "*" in accessible_customers:
68 + return list(requested_customers)
69 +
70 + # Scoped user: only honor requested codes they actually have access to.
71 + return [code for code in requested_customers if code in accessible_customers]
72 +
73 + async def filter_query_by_customer_access(
74 + self,
75 + user: User,
76 + session: AsyncSession,
77 + base_query,
78 + customer_code_field,
79 + requested_customers: Optional[List[str]] = None,
80 + ):
81 + """Filter any query by user's customer access.
82 +
83 + When ``requested_customers`` is provided, the query is further narrowed to
84 + that subset (intersected with the user's access — see
85 + ``resolve_effective_customers``).
86 + """
87 + accessible_customers = await self.resolve_effective_customers(user, requested_customers, session)
88 +
89 + # Admin/analyst see everything (no subset requested)
90 if "*" in accessible_customers:
91 return base_query
92
50 - # Customer users see only their data
93 + # Customer users (or anyone with a requested subset) see only matching data
94 if accessible_customers:
95 return base_query.where(customer_code_field.in_(accessible_customers))
96
54 - # No access - return empty result
97 + # No access / requested subset resolved to nothing - return empty result
98 return base_query.where(False)
99
100 def require_customer_access(self, customer_code: Optional[str] = None):
customer-portal/src/api/endpoints/alerts.ts
+6 -5
@@ -2,6 +2,7 @@ import type { Alert, AlertsFilters, AlertsListResponse, AlertStatus } from "@/ty
2 import type { CommentItem } from "@/types/comments"
3 import type { CommonResponse, Pagination } from "@/types/common"
4 import { HttpClient } from "../httpClient"
5 +import { withCustomerCodes } from "../params"
6
7 export interface AlertCommentPayload {
8 alertId: number
@@ -14,11 +15,11 @@ export default {
15 /**
16 * Get all alerts with customer access control
17 */
17 - getAlerts({ page = 1, pageSize = 25, order = "desc" }: Pagination, signal?: AbortSignal) {
18 - return HttpClient.get<CommonResponse<AlertsListResponse>>("/incidents/db_operations/alerts", {
19 - params: { page, page_size: pageSize, order },
20 - signal
21 - })
18 + getAlerts({ page = 1, pageSize = 25, order = "desc" }: Pagination, signal?: AbortSignal, customerCodes?: string[]) {
19 + return HttpClient.get<CommonResponse<AlertsListResponse>>(
20 + "/incidents/db_operations/alerts",
21 + withCustomerCodes(customerCodes, { params: { page, page_size: pageSize, order }, signal })
22 + )
23 },
24
25 /**
customer-portal/src/api/endpoints/cases.ts
+6 -5
@@ -2,6 +2,7 @@ import type { Case, CaseDataStoreFile, CasesFilters, CasesListResponse, CaseStat
2 import type { CommentItem } from "@/types/comments"
3 import type { CommonResponse, Pagination } from "@/types/common"
4 import { HttpClient } from "../httpClient"
5 +import { withCustomerCodes } from "../params"
6
7 export interface CasePayload {
8 case_name: string
@@ -22,11 +23,11 @@ export default {
23 /**
24 * Get all cases with customer access control
25 */
25 - getCases({ page = 1, pageSize = 25, order = "desc" }: Pagination, signal?: AbortSignal) {
26 - return HttpClient.get<CommonResponse<CasesListResponse>>("/incidents/db_operations/cases", {
27 - params: { page, page_size: pageSize, order },
28 - signal
29 - })
26 + getCases({ page = 1, pageSize = 25, order = "desc" }: Pagination, signal?: AbortSignal, customerCodes?: string[]) {
27 + return HttpClient.get<CommonResponse<CasesListResponse>>(
28 + "/incidents/db_operations/cases",
29 + withCustomerCodes(customerCodes, { params: { page, page_size: pageSize, order }, signal })
30 + )
31 },
32
33 /**
customer-portal/src/api/endpoints/portal.ts
+7 -6
@@ -1,18 +1,19 @@
1 import type { CommonResponse } from "@/types/common"
2 import type { AlertsStats, CasesStats, DashboardStats, PortalSettings } from "@/types/portal"
3 import { HttpClient } from "../httpClient"
4 +import { withCustomerCodes } from "../params"
5
6 export default {
7 getSettings() {
8 return HttpClient.get<CommonResponse<{ settings: PortalSettings }>>("/customer_portal/settings")
9 },
9 - dashboardStats() {
10 - return HttpClient.get<CommonResponse<DashboardStats>>("/customer_portal/dashboard/stats")
10 + dashboardStats(customerCodes?: string[]) {
11 + return HttpClient.get<CommonResponse<DashboardStats>>("/customer_portal/dashboard/stats", withCustomerCodes(customerCodes))
12 },
12 - alertsStats() {
13 - return HttpClient.get<CommonResponse<AlertsStats>>("/customer_portal/dashboard/alert-stats")
13 + alertsStats(customerCodes?: string[]) {
14 + return HttpClient.get<CommonResponse<AlertsStats>>("/customer_portal/dashboard/alert-stats", withCustomerCodes(customerCodes))
15 },
15 - casesStats() {
16 - return HttpClient.get<CommonResponse<CasesStats>>("/customer_portal/dashboard/case-stats")
16 + casesStats(customerCodes?: string[]) {
17 + return HttpClient.get<CommonResponse<CasesStats>>("/customer_portal/dashboard/case-stats", withCustomerCodes(customerCodes))
18 }
19 }
customer-portal/src/api/params.ts new
+22
@@ -0,0 +1,22 @@
1 +import type { AxiosRequestConfig } from "axios"
2 +
3 +/**
4 + * Merge an optional multi-customer filter into an axios request config.
5 + *
6 + * When `customerCodes` has entries they are appended as repeated query params
7 + * (`customer_codes=a&customer_codes=b`) — the shape FastAPI's
8 + * `List[str] = Query(...)` expects — via `paramsSerializer: { indexes: null }`.
9 + * When empty/undefined the original config is returned untouched, so callers
10 + * fall back to "all accessible customers" exactly as before.
11 + */
12 +export function withCustomerCodes(customerCodes?: string[], config: AxiosRequestConfig = {}): AxiosRequestConfig {
13 + if (!customerCodes?.length) {
14 + return config
15 + }
16 +
17 + return {
18 + ...config,
19 + params: { ...(config.params ?? {}), customer_codes: customerCodes },
20 + paramsSerializer: { indexes: null }
21 + }
22 +}
customer-portal/src/app-layouts/HorizontalNav/HeaderBar.vue
+7
@@ -4,11 +4,13 @@
4 <n-scrollbar class="grow" x-scrollable>
5 <Navbar :collapsed="false" mode="horizontal" />
6 </n-scrollbar>
7 + <CustomerFilter class="customer-filter-slot" />
8 </div>
9 </template>
10
11 <script lang="ts" setup>
12 import { NScrollbar } from "naive-ui"
13 +import CustomerFilter from "@/app-layouts/common/CustomerFilter.vue"
14 import Logo from "@/app-layouts/common/Logo.vue"
15 import Navbar from "@/app-layouts/common/Navbar"
16 </script>
@@ -32,6 +34,11 @@ import Navbar from "@/app-layouts/common/Navbar"
34 margin-right: var(--view-padding);
35 }
36
37 + .customer-filter-slot {
38 + flex-shrink: 0;
39 + margin-right: var(--view-padding);
40 + }
41 +
42 @media (max-width: $sidebar-bp) {
43 display: none;
44 }
customer-portal/src/app-layouts/common/CustomerFilter.vue new
+43
@@ -0,0 +1,43 @@
1 +<template>
2 + <NSelect
3 + v-if="options.length > 1"
4 + v-model:value="selected"
5 + multiple
6 + clearable
7 + size="small"
8 + class="customer-filter"
9 + :options
10 + :max-tag-count="2"
11 + placeholder="All customers"
12 + :consistent-menu-width="false"
13 + />
14 +</template>
15 +
16 +<script lang="ts" setup>
17 +import { NSelect } from "naive-ui"
18 +import { computed, onMounted } from "vue"
19 +import { useAuthStore } from "@/stores/auth"
20 +import { useCustomerFilterStore } from "@/stores/customerFilter"
21 +
22 +const authStore = useAuthStore()
23 +const customerFilterStore = useCustomerFilterStore()
24 +
25 +const options = computed(() => authStore.accessibleCustomerCodes.map(code => ({ label: code, value: code })))
26 +
27 +const selected = computed<string[]>({
28 + get: () => customerFilterStore.selectedCustomerCodes,
29 + set: (codes: string[]) => customerFilterStore.setSelected(codes)
30 +})
31 +
32 +// Drop any persisted selection the current user can no longer access.
33 +onMounted(() => {
34 + customerFilterStore.pruneToAccessible(authStore.accessibleCustomerCodes)
35 +})
36 +</script>
37 +
38 +<style lang="scss" scoped>
39 +.customer-filter {
40 + min-width: 200px;
41 + max-width: 280px;
42 +}
43 +</style>
customer-portal/src/components/alerts/AlertsOverviewStatsCards.vue
+7 -2
@@ -32,11 +32,12 @@
32 import type { ApiError } from "@/types/common"
33 import type { AlertsStats } from "@/types/portal"
34 import { NSpin, useMessage } from "naive-ui"
35 -import { onBeforeMount, ref } from "vue"
35 +import { onBeforeMount, ref, watch } from "vue"
36 import Api from "@/api"
37 import CardStats from "@/components/common/cards/CardStats.vue"
38 import Icon from "@/components/common/Icon.vue"
39 import { ICONS } from "@/const"
40 +import { useCustomerFilterStore } from "@/stores/customerFilter"
41 import { getApiErrorMessage } from "@/utils"
42
43 const stats = ref<AlertsStats>({
@@ -48,12 +49,13 @@ const stats = ref<AlertsStats>({
49
50 const loading = ref(false)
51 const message = useMessage()
52 +const customerFilterStore = useCustomerFilterStore()
53
54 function fetchStats() {
55 loading.value = true
56
57 Api.portal
56 - .alertsStats()
58 + .alertsStats(customerFilterStore.queryCustomerCodes)
59 .then(res => {
60 stats.value = res.data
61 })
@@ -68,4 +70,7 @@ function fetchStats() {
70 onBeforeMount(() => {
71 fetchStats()
72 })
73 +
74 +// Refetch whenever the global customer filter changes.
75 +watch(() => customerFilterStore.selectedCustomerCodes, fetchStats, { deep: true })
76 </script>
customer-portal/src/components/alerts/List.vue
+14 -4
@@ -64,7 +64,7 @@ import type { Alert, AlertsListResponse, AlertStatus } from "@/types/alerts"
64 import type { ApiError, CommonResponse, Pagination } from "@/types/common"
65 import { useDebounceFn, useElementSize } from "@vueuse/core"
66 import axios from "axios"
67 -import { NDataTable, NEllipsis, NEmpty, NPagination, NTag, useMessage } from "naive-ui"
67 +import { NDataTable, NEmpty, NPagination, NTag, useMessage } from "naive-ui"
68 import { computed, ref, useTemplateRef, watch } from "vue"
69 import Api from "@/api"
70 import AlertDetailsButton from "@/components/alerts/AlertDetailsButton.vue"
@@ -72,6 +72,7 @@ import AlertStatusSelect from "@/components/alerts/AlertStatusSelect.vue"
72 import Filters from "@/components/alerts/Filters.vue"
73 import Chip from "@/components/common/Chip.vue"
74 import Icon from "@/components/common/Icon.vue"
75 +import { useCustomerFilterStore } from "@/stores/customerFilter"
76 import { useSettingsStore } from "@/stores/settings"
77 import { getApiErrorMessage, getStatusColor } from "@/utils"
78 import { formatDate } from "@/utils/format"
@@ -80,6 +81,7 @@ const message = useMessage()
81 const data = ref<Alert[]>([])
82 const loading = ref(false)
83 const dFormats = useSettingsStore().dateFormat
84 +const customerFilterStore = useCustomerFilterStore()
85
86 const { width: headerWidthRef } = useElementSize(useTemplateRef("headerRef"))
87 const pageSizes = [10, 25, 50, 100]
@@ -203,11 +205,19 @@ const loadAlerts = useDebounceFn(async () => {
205 )
206 break
207 default:
206 - response = await Api.alerts.getAlerts(paginationPayload, abortController.signal)
208 + response = await Api.alerts.getAlerts(
209 + paginationPayload,
210 + abortController.signal,
211 + customerFilterStore.queryCustomerCodes
212 + )
213 break
214 }
215 } else {
210 - response = await Api.alerts.getAlerts(paginationPayload, abortController.signal)
216 + response = await Api.alerts.getAlerts(
217 + paginationPayload,
218 + abortController.signal,
219 + customerFilterStore.queryCustomerCodes
220 + )
221 }
222
223 data.value = response.data.alerts
@@ -233,7 +243,7 @@ function handleStatusUpdateSuccess(payload: AlertStatusUpdateSuccessPayload) {
243 }
244 }
245
236 -watch([() => pagination.value.pageSize, () => filters.value.value], resetPage, {
246 +watch([() => pagination.value.pageSize, () => filters.value.value, () => customerFilterStore.selectedCustomerCodes], resetPage, {
247 deep: true,
248 immediate: true
249 })
customer-portal/src/components/cases/CasesOverviewStatsCards.vue
+7 -2
@@ -32,11 +32,12 @@
32 import type { ApiError } from "@/types/common"
33 import type { CasesStats } from "@/types/portal"
34 import { NSpin, useMessage } from "naive-ui"
35 -import { onBeforeMount, ref } from "vue"
35 +import { onBeforeMount, ref, watch } from "vue"
36 import Api from "@/api"
37 import CardStats from "@/components/common/cards/CardStats.vue"
38 import Icon from "@/components/common/Icon.vue"
39 import { ICONS } from "@/const"
40 +import { useCustomerFilterStore } from "@/stores/customerFilter"
41 import { getApiErrorMessage } from "@/utils"
42
43 const stats = ref<CasesStats>({
@@ -48,12 +49,13 @@ const stats = ref<CasesStats>({
49
50 const loading = ref(false)
51 const message = useMessage()
52 +const customerFilterStore = useCustomerFilterStore()
53
54 function fetchStats() {
55 loading.value = true
56
57 Api.portal
56 - .casesStats()
58 + .casesStats(customerFilterStore.queryCustomerCodes)
59 .then(res => {
60 stats.value = res.data
61 })
@@ -68,4 +70,7 @@ function fetchStats() {
70 onBeforeMount(() => {
71 fetchStats()
72 })
73 +
74 +// Refetch whenever the global customer filter changes.
75 +watch(() => customerFilterStore.selectedCustomerCodes, fetchStats, { deep: true })
76 </script>
customer-portal/src/components/cases/List.vue
+5 -3
@@ -76,6 +76,7 @@ import CreateCaseButton from "@/components/cases/CreateCaseButton.vue"
76 import Filters from "@/components/cases/Filters.vue"
77 import Chip from "@/components/common/Chip.vue"
78 import Icon from "@/components/common/Icon.vue"
79 +import { useCustomerFilterStore } from "@/stores/customerFilter"
80 import { useSettingsStore } from "@/stores/settings"
81 import { getApiErrorMessage, getStatusColor } from "@/utils"
82 import { formatDate } from "@/utils/format"
@@ -84,6 +85,7 @@ const message = useMessage()
85 const data = ref<Case[]>([])
86 const loading = ref(false)
87 const dFormats = useSettingsStore().dateFormat
88 +const customerFilterStore = useCustomerFilterStore()
89
90 const { width: headerWidthRef } = useElementSize(useTemplateRef("headerRef"))
91 const pageSizes = [10, 25, 50, 100]
@@ -204,11 +206,11 @@ const loadCases = useDebounceFn(async () => {
206 )
207 break
208 default:
207 - response = await Api.cases.getCases(paginationPayload, abortController.signal)
209 + response = await Api.cases.getCases(paginationPayload, abortController.signal, customerFilterStore.queryCustomerCodes)
210 break
211 }
212 } else {
211 - response = await Api.cases.getCases(paginationPayload, abortController.signal)
213 + response = await Api.cases.getCases(paginationPayload, abortController.signal, customerFilterStore.queryCustomerCodes)
214 }
215
216 data.value = response.data.cases
@@ -265,7 +267,7 @@ function handleStatusUpdateSuccess(payload: CaseStatusUpdateSuccessPayload) {
267 }
268 }
269
268 -watch([() => pagination.value.pageSize, () => filters.value.value], resetPage, {
270 +watch([() => pagination.value.pageSize, () => filters.value.value, () => customerFilterStore.selectedCustomerCodes], resetPage, {
271 deep: true,
272 immediate: true
273 })
customer-portal/src/components/overview/OverviewStatsCards.vue
+7 -2
@@ -26,17 +26,19 @@
26 import type { ApiError } from "@/types/common"
27 import type { DashboardStats } from "@/types/portal"
28 import { NSpin, useMessage } from "naive-ui"
29 -import { onBeforeMount, ref } from "vue"
29 +import { onBeforeMount, ref, watch } from "vue"
30 import Api from "@/api"
31 import CardStats from "@/components/common/cards/CardStats.vue"
32 import Icon from "@/components/common/Icon.vue"
33 import { useNavigation } from "@/composables/common/useNavigation"
34 import { ICONS } from "@/const"
35 +import { useCustomerFilterStore } from "@/stores/customerFilter"
36 import { getApiErrorMessage } from "@/utils"
37
38 const { routeAlertsList, routeCasesList, routeAgentsList } = useNavigation()
39 const loading = ref(false)
40 const message = useMessage()
41 +const customerFilterStore = useCustomerFilterStore()
42 const stats = ref<DashboardStats>({
43 total_alerts: 0,
44 total_cases: 0,
@@ -46,7 +48,7 @@ const stats = ref<DashboardStats>({
48 function fetchStats() {
49 loading.value = true
50 Api.portal
49 - .dashboardStats()
51 + .dashboardStats(customerFilterStore.queryCustomerCodes)
52 .then(res => {
53 stats.value = res.data
54 })
@@ -61,4 +63,7 @@ function fetchStats() {
63 onBeforeMount(() => {
64 fetchStats()
65 })
66 +
67 +// Refetch whenever the global customer filter changes.
68 +watch(() => customerFilterStore.selectedCustomerCodes, fetchStats, { deep: true })
69 </script>
customer-portal/src/stores/auth.ts
+6
@@ -6,6 +6,7 @@ import _capitalize from "lodash/capitalize"
6 import _castArray from "lodash/castArray"
7 import { acceptHMRUpdate, defineStore } from "pinia"
8 import Api from "@/api"
9 +import { useCustomerFilterStore } from "@/stores/customerFilter"
10 import { RouteRole } from "@/types/auth"
11 import { getAvatar } from "@/utils"
12 import { jwtRoleToUserRole } from "@/utils/auth"
@@ -26,6 +27,7 @@ export const useAuthStore = defineStore("auth", {
27 refresh_token: payload.refresh_token,
28 username: jwtPayload.sub || "",
29 customer_code: jwtPayload.customer_codes?.[0] || null,
30 + customer_codes: jwtPayload.customer_codes ?? [],
31 role: jwtRoleToUserRole(jwtPayload.scopes)
32 }
33 },
@@ -38,6 +40,7 @@ export const useAuthStore = defineStore("auth", {
40 setLogout() {
41 this.user = null
42
43 + useCustomerFilterStore().clear()
44 removePersistentSessionKey()
45 },
46 async login(payload: LoginPayload) {
@@ -84,6 +87,9 @@ export const useAuthStore = defineStore("auth", {
87 userCustomerCode(state): string | null {
88 return state.user?.customer_code || null
89 },
90 + accessibleCustomerCodes(state): string[] {
91 + return state.user?.customer_codes ?? []
92 + },
93 userName(state): string | null {
94 return state.user?.username || null
95 },
customer-portal/src/stores/customerFilter.ts new
+50
@@ -0,0 +1,50 @@
1 +import { acceptHMRUpdate, defineStore } from "pinia"
2 +
3 +/**
4 + * Holds the Customer Portal's global multi-customer filter.
5 + *
6 + * An empty `selectedCustomerCodes` means "All accessible customers" (the default,
7 + * preserving the previous behaviour). When one or more codes are selected, portal
8 + * data is scoped to that subset. The backend always intersects the requested codes
9 + * with the user's accessible customers, so a stale/invalid code here can never widen
10 + * access — at worst it is ignored.
11 + */
12 +export const useCustomerFilterStore = defineStore("customer-filter", {
13 + state: () => ({
14 + selectedCustomerCodes: [] as string[]
15 + }),
16 + actions: {
17 + setSelected(codes: string[]) {
18 + this.selectedCustomerCodes = [...codes]
19 + },
20 + clear() {
21 + this.selectedCustomerCodes = []
22 + },
23 + /** Drop any selected codes the user no longer has access to. */
24 + pruneToAccessible(accessibleCodes: string[]) {
25 + if (!this.selectedCustomerCodes.length) {
26 + return
27 + }
28 + this.selectedCustomerCodes = this.selectedCustomerCodes.filter(code => accessibleCodes.includes(code))
29 + }
30 + },
31 + getters: {
32 + isFiltering(state): boolean {
33 + return state.selectedCustomerCodes.length > 0
34 + },
35 + /**
36 + * The value to pass to customer-scoped API calls: the selected subset, or
37 + * `undefined` when nothing is selected (meaning "all accessible").
38 + */
39 + queryCustomerCodes(state): string[] | undefined {
40 + return state.selectedCustomerCodes.length ? state.selectedCustomerCodes : undefined
41 + }
42 + },
43 + persist: {
44 + pick: ["selectedCustomerCodes"]
45 + }
46 +})
47 +
48 +if (import.meta.hot) {
49 + import.meta.hot.accept(acceptHMRUpdate(useCustomerFilterStore, import.meta.hot))
50 +}
customer-portal/src/types/auth.ts
+1
@@ -26,6 +26,7 @@ export interface AuthUser {
26 role: AuthUserRole | string | null
27 username: string | null
28 customer_code: string | null
29 + customer_codes: string[]
30 }
31
32 export interface AuthResponse {
customer-portal/src/views/Overview.vue
+23 -3
@@ -39,11 +39,12 @@
39 import type { DashboardAlert, DashboardCase } from "@/components/overview/types"
40 import type { ApiError } from "@/types/common"
41 import { NSpin, useMessage } from "naive-ui"
42 -import { onBeforeMount, ref } from "vue"
42 +import { onBeforeMount, ref, watch } from "vue"
43 import Api from "@/api"
44 import OverviewRecentAlerts from "@/components/overview/OverviewRecentAlerts.vue"
45 import OverviewRecentCases from "@/components/overview/OverviewRecentCases.vue"
46 import OverviewStatsCards from "@/components/overview/OverviewStatsCards.vue"
47 +import { useCustomerFilterStore } from "@/stores/customerFilter"
48 import { getApiErrorMessage } from "@/utils"
49
50 const loadingAlerts = ref(false)
@@ -51,13 +52,18 @@ const loadingCases = ref(false)
52 const message = useMessage()
53 const recentAlerts = ref<DashboardAlert[]>([])
54 const recentCases = ref<DashboardCase[]>([])
55 +const customerFilterStore = useCustomerFilterStore()
56
57 async function fetchAlerts() {
58 loadingAlerts.value = true
59
60 try {
61 // Fetch alerts, cases, and agents data using our API services
60 - const alertsResponse = await Api.alerts.getAlerts({ page: 1, pageSize: 10, order: "desc" })
62 + const alertsResponse = await Api.alerts.getAlerts(
63 + { page: 1, pageSize: 10, order: "desc" },
64 + undefined,
65 + customerFilterStore.queryCustomerCodes
66 + )
67
68 const alerts = alertsResponse.data.alerts || []
69
@@ -84,7 +90,11 @@ async function fetchCases() {
90
91 try {
92 // Fetch alerts, cases, and agents data using our API services
87 - const casesResponse = await Api.cases.getCases({ page: 1, pageSize: 10, order: "desc" })
93 + const casesResponse = await Api.cases.getCases(
94 + { page: 1, pageSize: 10, order: "desc" },
95 + undefined,
96 + customerFilterStore.queryCustomerCodes
97 + )
98
99 const cases = casesResponse.data.cases || []
100
@@ -111,4 +121,14 @@ onBeforeMount(() => {
121 fetchAlerts()
122 fetchCases()
123 })
124 +
125 +// Refetch whenever the global customer filter changes.
126 +watch(
127 + () => customerFilterStore.selectedCustomerCodes,
128 + () => {
129 + fetchAlerts()
130 + fetchCases()
131 + },
132 + { deep: true }
133 +)
134 </script>